mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
422
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8dacebc45 | ||
|
|
880a9c084e | ||
|
|
75497cf0f7 | ||
|
|
72682f4308 | ||
|
|
f1585b010d | ||
|
|
1f88220d01 | ||
|
|
75e3b1c722 | ||
|
|
0c7eb677b8 | ||
|
|
0b10f9553c | ||
|
|
69a2e8c264 | ||
|
|
b8ec50c7f7 | ||
|
|
f74b55ddbc | ||
|
|
846ee30bce | ||
|
|
fbd94c424b | ||
|
|
987513a5f6 | ||
|
|
ea0f7c17b5 | ||
|
|
4493269479 | ||
|
|
051171b019 | ||
|
|
750ad9b1cc | ||
|
|
3841fa80d3 | ||
|
|
04ceac5a97 | ||
|
|
ada292574f | ||
|
|
15190413b2 | ||
|
|
f637ba0191 | ||
|
|
b9767a7471 | ||
|
|
47b29358ae | ||
|
|
cb66d77e9c | ||
|
|
9f5e27a277 | ||
|
|
486c3a0a69 | ||
|
|
8359c56dc7 | ||
|
|
a97e72ef30 | ||
|
|
a1cc0b8574 | ||
|
|
81b8953d52 | ||
|
|
1a3ccd0e38 | ||
|
|
ec0a027a1e | ||
|
|
19306002e3 | ||
|
|
8e89a078a7 | ||
|
|
4cd269d852 | ||
|
|
49a47f5e2b | ||
|
|
952bd26d2d | ||
|
|
7e58e8a611 | ||
|
|
2e05d276bf | ||
|
|
9a32f3c602 | ||
|
|
2413a9a3a1 | ||
|
|
03690952cb | ||
|
|
c8227faf31 | ||
|
|
beda8d626c | ||
|
|
08fe350e02 | ||
|
|
6dc99d88e2 | ||
|
|
11c0948330 | ||
|
|
0914cfeb2f | ||
|
|
d796dd48ff | ||
|
|
399cf0cf41 | ||
|
|
b4c3cc0539 | ||
|
|
dc763ec689 | ||
|
|
b9fe770ab1 | ||
|
|
657c198a37 | ||
|
|
488734c0db | ||
|
|
6e0a0f7392 | ||
|
|
6a72833c2a | ||
|
|
b672d76634 | ||
|
|
eb3523f374 | ||
|
|
6bde51c991 | ||
|
|
8998b9623f | ||
|
|
322335dfc5 | ||
|
|
efe47c5bbc | ||
|
|
35bdd2a168 | ||
|
|
540bf8c032 | ||
|
|
333f7606e4 | ||
|
|
8c84090b63 | ||
|
|
3277736f5d | ||
|
|
8bd0aae7c3 | ||
|
|
9c796ec05c | ||
|
|
b5b3585e77 | ||
|
|
179e7c248a | ||
|
|
3a9504500b | ||
|
|
6debe23034 | ||
|
|
641da086e5 | ||
|
|
9d55bea240 | ||
|
|
f2b14b5f00 | ||
|
|
470a62129a | ||
|
|
32718f0118 | ||
|
|
978e6ee7f2 | ||
|
|
77d71fe418 | ||
|
|
50bdaae26a | ||
|
|
e1171dbcfe | ||
|
|
9a9f68c446 | ||
|
|
848b3931b8 | ||
|
|
828fd21e29 | ||
|
|
6c16e2d966 | ||
|
|
b352d71528 | ||
|
|
92bdfcb294 | ||
|
|
f8743a904f | ||
|
|
f6e42811a5 | ||
|
|
08260182a9 | ||
|
|
de4343cfbb | ||
|
|
af50d69b00 | ||
|
|
e1439a5899 | ||
|
|
5ae5168b6b | ||
|
|
4cb1da29d4 | ||
|
|
f9caea6c79 | ||
|
|
a12c419dd2 | ||
|
|
af326a37b7 | ||
|
|
45f5d236c1 | ||
|
|
ea6820204d | ||
|
|
e849db2286 | ||
|
|
e12cbf8863 | ||
|
|
4e148302fc | ||
|
|
9c861f487d | ||
|
|
70c0273ae7 | ||
|
|
e2fa288f48 | ||
|
|
59a9e742c8 | ||
|
|
31d3830791 | ||
|
|
d1bd010260 | ||
|
|
fbb127a86d | ||
|
|
7b2bd0ea6a | ||
|
|
8e5e9c810e | ||
|
|
e104093614 | ||
|
|
b387d6581e | ||
|
|
17ab477af6 | ||
|
|
10e29476f4 | ||
|
|
215e328f17 | ||
|
|
3346286ffd | ||
|
|
72ffd44f68 | ||
|
|
b090a8ec21 | ||
|
|
f98ce47c52 | ||
|
|
0c7f7e128b | ||
|
|
0cc77fdee0 | ||
|
|
3b2b2185a5 | ||
|
|
253c0a46a1 | ||
|
|
a1a70dea5d | ||
|
|
43c0175334 | ||
|
|
c1d73b6ba8 | ||
|
|
66230e6b43 | ||
|
|
952f1fbad3 | ||
|
|
365352cdd3 | ||
|
|
45414b3baa | ||
|
|
f142c514cd | ||
|
|
e47fc76529 | ||
|
|
0fe0e973e6 | ||
|
|
28cff59a6f | ||
|
|
b17057cc41 | ||
|
|
15fcb11f4f | ||
|
|
9d8c95b2f9 | ||
|
|
e36a049216 | ||
|
|
3042f87199 | ||
|
|
a21a01f0dd | ||
|
|
78cf516c4c | ||
|
|
0c59a9f84a | ||
|
|
d2ab888114 | ||
|
|
0073d7d612 | ||
|
|
e0acbf5457 | ||
|
|
d763043305 | ||
|
|
821fa0669d | ||
|
|
8fbc522d06 | ||
|
|
271504eec1 | ||
|
|
c9a0719128 | ||
|
|
0514162824 | ||
|
|
60c8a5c993 | ||
|
|
fb7f187ede | ||
|
|
1fd5dbaba3 | ||
|
|
3433ccb661 | ||
|
|
694ff77712 | ||
|
|
06aaec4ba5 | ||
|
|
62b5b09857 | ||
|
|
05f090d83a | ||
|
|
4244695075 | ||
|
|
6f21b6354a | ||
|
|
3a34c01abb | ||
|
|
ba11519fdb | ||
|
|
d3fd547ea0 | ||
|
|
75f09694a3 | ||
|
|
6d56f1f371 | ||
|
|
64328d7bdf | ||
|
|
287dd240b1 | ||
|
|
22ec7328fa | ||
|
|
540641700d | ||
|
|
3422d45eeb | ||
|
|
f7626f40b3 | ||
|
|
de2c956c31 | ||
|
|
3802c90d2d | ||
|
|
839f777a75 | ||
|
|
dd6affca6d | ||
|
|
bea2f92508 | ||
|
|
f19f56a666 | ||
|
|
140020f63a | ||
|
|
6e10764d28 | ||
|
|
9bfe831022 | ||
|
|
bbdd4cbc78 | ||
|
|
c57cd35f09 | ||
|
|
1a1f365cf1 | ||
|
|
d1544bab42 | ||
|
|
e0d17cf404 | ||
|
|
b2de607391 | ||
|
|
355793b7e6 | ||
|
|
593b42830c | ||
|
|
463a8e2f8a | ||
|
|
88ac7d2a68 | ||
|
|
53d28de17e | ||
|
|
ca07ebc3b7 | ||
|
|
bf1fb1fd88 | ||
|
|
846fb2c28c | ||
|
|
34655e5f50 | ||
|
|
491bc53dec | ||
|
|
8f5bb26b3a | ||
|
|
4ba4b013b0 | ||
|
|
484202e90d | ||
|
|
9f0ac74fbc | ||
|
|
51c954993a | ||
|
|
907a6651f5 | ||
|
|
1ca1ca9d0c | ||
|
|
d70664067c | ||
|
|
5177b41951 | ||
|
|
bb86f7e9ed | ||
|
|
221f7c8dcf | ||
|
|
cf76e0f69a | ||
|
|
abf60141d9 | ||
|
|
ce15f36e5e | ||
|
|
1f0f162f66 | ||
|
|
cd6f2c6078 | ||
|
|
696b224f8d | ||
|
|
7a8666efbb | ||
|
|
f0c979ce71 | ||
|
|
d810a2ec0f | ||
|
|
554501cc74 | ||
|
|
aaf6ba2ab4 | ||
|
|
547aaa304e | ||
|
|
f09a589940 | ||
|
|
a861b41809 | ||
|
|
e9b3ba0075 | ||
|
|
0401534b88 | ||
|
|
f720da49d6 | ||
|
|
3abb468cca | ||
|
|
c8daa14219 | ||
|
|
81cd8a2bec | ||
|
|
35210cf570 | ||
|
|
d2bad58aa8 | ||
|
|
f73ca1f61b | ||
|
|
a0f290459d | ||
|
|
b6b9aa07bc | ||
|
|
a39fc25104 | ||
|
|
1a11489f2e | ||
|
|
c0504d65a5 | ||
|
|
780726a4e3 | ||
|
|
a73f797bcb | ||
|
|
b0b61f8fce | ||
|
|
7be4f13774 | ||
|
|
6af0e97f51 | ||
|
|
ceb24595e6 | ||
|
|
fb7feec5cf | ||
|
|
175b8e6065 | ||
|
|
f46b37192f | ||
|
|
a8b056d2aa | ||
|
|
e148c1556e | ||
|
|
e2e888710a | ||
|
|
7509814abc | ||
|
|
0dc8746914 | ||
|
|
2560fc87c2 | ||
|
|
6db35f704d | ||
|
|
490b2d57dc | ||
|
|
486979841e | ||
|
|
ee5673a22f | ||
|
|
d1c9e14bf0 | ||
|
|
17c11f0512 | ||
|
|
df5d125279 | ||
|
|
5914f41ea1 | ||
|
|
68a2fb2943 | ||
|
|
34523cb00f | ||
|
|
8ea1a64c59 | ||
|
|
df79976933 | ||
|
|
d3af918f53 | ||
|
|
f7c16888e7 | ||
|
|
a2982948a7 | ||
|
|
b7e1f9f30b | ||
|
|
7fc5186c24 | ||
|
|
de92c8bcd4 | ||
|
|
6b143e1a56 | ||
|
|
5728209573 | ||
|
|
02d8b39d75 | ||
|
|
f87aaaf8c3 | ||
|
|
8861f39ecc | ||
|
|
6d5755ced7 | ||
|
|
997bb17ceb | ||
|
|
ea6501ea81 | ||
|
|
1d2da33d3c | ||
|
|
0e68e287b7 | ||
|
|
ecdb2d253e | ||
|
|
8860347bfe | ||
|
|
beb99f1d8e | ||
|
|
63f1ced2bc | ||
|
|
fa6013d22e | ||
|
|
6b60792277 | ||
|
|
c562fe05b0 | ||
|
|
9efa818bbb | ||
|
|
d757e69bf4 | ||
|
|
88ff62c829 | ||
|
|
8cccf2ed24 | ||
|
|
f6b82b8e21 | ||
|
|
51d7ce61bd | ||
|
|
20f4cfacea | ||
|
|
7d9081efc1 | ||
|
|
a2453784c1 | ||
|
|
0f9c7b3c4c | ||
|
|
914180792d | ||
|
|
935656dc4f | ||
|
|
2dd6a9e20d | ||
|
|
ffa27d7224 | ||
|
|
edd0fc58eb | ||
|
|
366edf7843 | ||
|
|
ff5502be2f | ||
|
|
4407cd0c67 | ||
|
|
b8c12e0291 | ||
|
|
e7411ee7fb | ||
|
|
ce88d12a60 | ||
|
|
719ceae303 | ||
|
|
52ece9321f | ||
|
|
0bf34d8652 | ||
|
|
9179780afb | ||
|
|
f76500cfb2 | ||
|
|
7eb96d9068 | ||
|
|
001fb5a451 | ||
|
|
73f3f7749a | ||
|
|
fab8c194e3 | ||
|
|
caeef65cdb | ||
|
|
ad4fb5d3c7 | ||
|
|
44b0b5c31c | ||
|
|
3e795d9a83 | ||
|
|
11c6a3f51b | ||
|
|
30fd8a19b8 | ||
|
|
390d3fa782 | ||
|
|
c4b7cfaa2f | ||
|
|
94c60ad465 | ||
|
|
58bdfb94a9 | ||
|
|
59fb3ee3bd | ||
|
|
221079b89c | ||
|
|
892d9f7959 | ||
|
|
3d0aca52d0 | ||
|
|
4301da2e37 | ||
|
|
82655afa20 | ||
|
|
5799891e4f | ||
|
|
900191eca5 | ||
|
|
e8d2f1ad98 | ||
|
|
ec02887d22 | ||
|
|
ac55a346be | ||
|
|
a216709227 | ||
|
|
4a990bfde3 | ||
|
|
e5ac3ad896 | ||
|
|
652424b74c | ||
|
|
877c431b13 | ||
|
|
e8d14a695f | ||
|
|
fe78bf812c | ||
|
|
64ecd92d7c | ||
|
|
f41cd888f9 | ||
|
|
4de8ac1810 | ||
|
|
f9e2a210b7 | ||
|
|
62fb67933d | ||
|
|
43f4dd94aa | ||
|
|
1a5bd74357 | ||
|
|
31e5e08def | ||
|
|
5d74979af3 | ||
|
|
24da8cfc2e | ||
|
|
e3b4e1634d | ||
|
|
2f186ec002 | ||
|
|
08170a2574 | ||
|
|
7eb8477dad | ||
|
|
2fb0cd2489 | ||
|
|
8dcdf01129 | ||
|
|
da26435385 | ||
|
|
364112d94f | ||
|
|
82a23d291e | ||
|
|
1552fa1a39 | ||
|
|
47514f57aa | ||
|
|
1bd6ab9975 | ||
|
|
314d6ec955 | ||
|
|
66ae948371 | ||
|
|
fecda7cce3 | ||
|
|
b9e42d543c | ||
|
|
4406eeacbe | ||
|
|
0408f6ce3c | ||
|
|
47b69a1f95 | ||
|
|
0e992f2f5f | ||
|
|
7785cc972b | ||
|
|
4b412e6277 | ||
|
|
f0a0ae5f2c | ||
|
|
a56dc5bc85 | ||
|
|
1a663156c3 | ||
|
|
e92628a6b4 | ||
|
|
b4ad78ce84 | ||
|
|
30aced6d36 | ||
|
|
da5b34f5f4 | ||
|
|
010aa8aa8c | ||
|
|
05edca4a9a | ||
|
|
e2b1a5e055 | ||
|
|
4eac2e4dde | ||
|
|
78380f27d4 | ||
|
|
47e0d0f288 | ||
|
|
dab02ede9c | ||
|
|
c950c7402f | ||
|
|
c86d70ca08 | ||
|
|
d7107677c0 | ||
|
|
5647cf6d36 | ||
|
|
3aa06006fe | ||
|
|
ac9f447ad2 | ||
|
|
5692d043c9 | ||
|
|
49879f987c | ||
|
|
ca4cb1790a | ||
|
|
4643f70f4a | ||
|
|
6e828b9c2d | ||
|
|
d5d6abeb1e | ||
|
|
b20ddf2603 | ||
|
|
419911b78b | ||
|
|
954bbddf7e | ||
|
|
b00b6637d2 | ||
|
|
7ad9559653 | ||
|
|
278f892fae | ||
|
|
80e92b77b8 | ||
|
|
185f592ce9 | ||
|
|
1379a062ba | ||
|
|
6184f38330 | ||
|
|
9e45193e2c | ||
|
|
e5fd35da32 | ||
|
|
9e51ba6cd4 |
No files matched your search
@@ -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 /)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,25 +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:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push * main)",
|
||||
"Bash(git push * master)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(rm -rf /)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra tag to publish (optional, e.g. "edge")'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: zhangtianyao1/teamspeak-music-bot
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: scripts/docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -6,3 +6,8 @@ dist/
|
||||
config.json
|
||||
cookies/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
.claude/
|
||||
setup.log
|
||||
/bin/
|
||||
scripts/navbar_bigger.png
|
||||
@@ -1,11 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/TeamSpeak_3-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
||||
<img src="https://img.shields.io/badge/TeamSpeak-音乐机器人-blue?style=for-the-badge&logo=teamspeak" alt="TSMusicBot" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TSMusicBot</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>TeamSpeak 3 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 哔哩哔哩 三平台,YesPlayMusic 风格 WebUI 控制面板
|
||||
<strong>TeamSpeak 音乐机器人</strong> — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩 + YouTube(可选),Jellyfin / Spotify 可选启用,YesPlayMusic 风格 WebUI 控制面板
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,24 +15,39 @@
|
||||
<img src="https://img.shields.io/badge/许可证-MIT-green" />
|
||||
<img src="https://img.shields.io/badge/FFmpeg-已内置-orange?logo=ffmpeg" />
|
||||
<img src="https://img.shields.io/badge/Docker-支持-2496ED?logo=docker&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
|
||||
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Jellyfin-可选-aa5cc3?logo=jellyfin&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
|
||||
</p>
|
||||
|
||||
---
|
||||
> v1.10.0 新增**可选**的 [Jellyfin](https://jellyfin.org/) 音源(由 [@ItsEricRao](https://github.com/ItsEricRao) 在 [PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123) 中贡献):连接自建 Jellyfin 服务器直接播放你自己的音乐库。默认关闭,在 **设置 → Jellyfin 音乐库** 一键开启;原有在线音源保持默认启用,行为不变。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **三平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩,统一搜索,结果标注来源
|
||||
- **真实 TS3 客户端协议** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),兼容 TS3/TS5/TS6 服务器
|
||||
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
|
||||
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
|
||||
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
|
||||
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
|
||||
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
|
||||
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
|
||||
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
|
||||
- **多平台音源(enabledProviders 门控)** — 网易云音乐 / QQ 音乐 / 酷狗音乐 / 哔哩哔哩 / YouTube(yt-dlp,需安装)**默认启用**,可在 `config.json` 的 `enabledProviders` 中逐个停用;Jellyfin 为可选音源(见上),**Spotify(实验性)** 由独立开关控制(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性))。统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源,禁用音源不出现在搜索栏
|
||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
|
||||
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
|
||||
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部
|
||||
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云、**QQ 音乐雷达推荐**(`!fm -q`)与**酷狗私人电台**(`!fm -k`)。网易云、QQ、酷狗均提供登录后的推荐歌单 / 每日推荐 / 我的歌单
|
||||
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
|
||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/酷狗音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||
- **播放历史** — 自动记录所有播放过的歌曲
|
||||
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
||||
@@ -40,7 +55,8 @@
|
||||
|
||||
## 截图
|
||||
|
||||
> *截图即将添加*
|
||||
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
|
||||
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -64,8 +80,8 @@ FFmpeg **已自动内置**,无需手动安装。
|
||||
|
||||
```bash
|
||||
# 下载项目
|
||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||
cd tsmusicbot
|
||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||
cd teamspeak-music-bot
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
@@ -85,8 +101,8 @@ npm start
|
||||
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
||||
cd tsmusicbot/scripts/docker
|
||||
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||
cd teamspeak-music-bot/scripts/docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
@@ -126,6 +142,107 @@ sudo ./scripts/install.sh
|
||||
|
||||
## 更新升级
|
||||
|
||||
> **⚠️ 从使用 `@honeybbq/teamspeak-client 0.1.x` 的旧版本升级时的重要变更**
|
||||
>
|
||||
> 本项目已将底层 TeamSpeak 协议库升级到 `0.2.x` 并移除了内置的 TS6 兼容层,改用库自带的通用 `clientinit` 协议。这涉及一次**数据库迁移**:
|
||||
>
|
||||
> **旧的身份(identity)不兼容新的加密握手路径。** `0.1.0` 版本的库在生成 TS 客户端身份时存在 P-256 公钥 DER 编码错误,该 bug 在 `0.1.1` 中由本项目维护者 [ZHANGTIANYAO1](https://github.com/HoneyBBQ/teamspeak-js/pull/5) 修复并合并到上游。`0.1.0` 生成的身份与 `0.2.x` 修复后的握手路径**不兼容**:升级后用旧身份连接会卡在 `received initivexpand2` 直到 15 秒超时。
|
||||
>
|
||||
> **解决办法**:升级后清空受影响机器人的 `identity` 字段,下次启动时程序会自动生成新身份并持久化。
|
||||
>
|
||||
> ```bash
|
||||
> # 对每个需要迁移的机器人执行(替换 <bot-id> 为实际 UUID):
|
||||
> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
|
||||
> db.execute(\"UPDATE bot_instances SET identity=NULL WHERE id='<bot-id>'\"); \
|
||||
> db.commit()"
|
||||
>
|
||||
> # 或者清空所有机器人的身份:
|
||||
> python -c "import sqlite3; db=sqlite3.connect('data/tsmusicbot.db'); \
|
||||
> db.execute('UPDATE bot_instances SET identity=NULL'); db.commit()"
|
||||
> ```
|
||||
>
|
||||
> **影响范围**:
|
||||
> - ✅ TS3 服务器 + 旧身份:在多数情况下仍可正常工作(TS3 对 legacy 编码更宽容),可选择不清空
|
||||
> - ❌ TS6 服务器 + 旧身份:**必须**清空身份才能连接
|
||||
> - ⚠️ 清空身份后,TS 服务器会把机器人识别为**全新的客户端**。之前手动赋予机器人的**服务器组需要用新 UID 重新授予一次**,之后每次重启都会自动保留
|
||||
>
|
||||
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
|
||||
|
||||
### 关于 enabledProviders 音源开关(v1.10.0 起)
|
||||
|
||||
v1.10.0([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123))引入 `enabledProviders` 音源开关与可选的 Jellyfin 音源。当前默认值为 `["netease", "qq", "bilibili", "youtube", "kugou"]`——**与旧版行为一致**,从更早版本升级**无需任何操作**,在线音源照常可用;Jellyfin 需要手动开启(详见 [可选:Jellyfin 音源](#可选jellyfin-音源))。
|
||||
|
||||
> ⚠️ **仅影响短暂运行过 v1.10.0 初版的用户**:该版本曾把默认音源设为 Jellyfin-only。如果你在那段时间保存过设置,`data/config.json` 中可能被写入了 `"enabledProviders": ["jellyfin"]`,升级后在线音源会保持停用。修复方法:把在线音源加回列表(或直接删除该字段以使用默认值),重启机器人(网易云 / QQ 的内嵌 API 服务需要重启才会启动):
|
||||
>
|
||||
> ```json
|
||||
> "enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
|
||||
> ```
|
||||
|
||||
### 从 WebUI 无鉴权版本升级(重要)
|
||||
|
||||
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
|
||||
|
||||
**升级行为**:
|
||||
|
||||
- 启动时数据库自动迁移:新增 `users`、`sessions`、`user_audit` 三张表;旧的 `bot_instances`、`play_history` 数据**完全保留**。
|
||||
- 第一次打开 WebUI 自动跳转到 `/first-run` 引导创建首位管理员(角色固定为 `admin`)。
|
||||
- 之后访问任何页面都会校验登录态,未登录跳转 `/login`。
|
||||
|
||||
**会话与 Cookie**:
|
||||
|
||||
- 登录态保存 7 天,每次请求滚动续期(活跃用户不会被踢出)。
|
||||
- 同一账号最多保持 10 个并发会话(超过自动剔除最旧的)。
|
||||
- Cookie 设置为 `HttpOnly; SameSite=Lax`,HTTPS 部署需配合 `trustProxy: true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
|
||||
|
||||
**多用户与角色**:
|
||||
|
||||
- 角色 `admin`:完整权限(用户管理、审计、机器人、音乐平台、播放控制)。
|
||||
- 角色 `member`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。
|
||||
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
|
||||
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
|
||||
|
||||
**游客模式 / Guest mode**:
|
||||
|
||||
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
|
||||
|
||||
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。游客共享同一匿名身份、会话有效期较短(约 1 天)。关闭游客模式(或缩小机器人作用域)后立即生效,所有在线游客会话——包括正在连接的实时 WebSocket——会被立刻断开 / 重新限制。
|
||||
- **逐项权限(8 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
|
||||
|
||||
| 开关 | 字段 | 默认 |
|
||||
|------|------|------|
|
||||
| 添加到队列末尾 | `addToQueue` | **开** |
|
||||
| 添加到下一首 | `playNext` | 关 |
|
||||
| 立即播放(不清空队列) | `playNow` | 关 |
|
||||
| 跳过当前歌曲 | `skip` | 关 |
|
||||
| 暂停/继续/进度/音量 | `transport` | 关 |
|
||||
| 移除/清空队列 | `removeClear` | 关 |
|
||||
| 切换播放模式 / FM | `playMode` | 关 |
|
||||
| 播放整个歌单/专辑 | `playCollection` | 关 |
|
||||
|
||||
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
|
||||
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 8 个开关影响,**永远锁死**。
|
||||
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
|
||||
|
||||
**如何重置忘记的管理员密码**:
|
||||
|
||||
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
|
||||
|
||||
```bash
|
||||
# 方案 1:清空所有用户,重新进入 first-run 流程
|
||||
sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"
|
||||
# 然后重启机器人,浏览器再次访问会自动进入 /first-run
|
||||
|
||||
# 方案 2:把指定用户重置为已知密码(密码 'changeme-now' 的 bcrypt 哈希示例如下)
|
||||
# 先用 node 生成哈希:
|
||||
node -e "console.log(require('bcryptjs').hashSync('changeme-now', 12))"
|
||||
# 把输出贴到 SQL 里:
|
||||
sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' WHERE username='你的用户名';"
|
||||
```
|
||||
|
||||
**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
|
||||
|
||||
**`config.adminGroups`(现已启用)**:用于限制管理类聊天命令(`stop`/`clear`/`remove`/`move`/`vol`/`mode`)只能由指定 TeamSpeak 服务器组的成员运行;为空时不做任何限制(向后兼容)。详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`config.adminPassword` 则是旧版预留字段,当前版本未使用,保留以兼容旧 `config.json`,可以放心忽略。
|
||||
|
||||
### Windows 用户
|
||||
|
||||
```
|
||||
@@ -186,27 +303,35 @@ npm run build
|
||||
sudo systemctl start tsmusicbot
|
||||
```
|
||||
|
||||
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
|
||||
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。但请注意本节开头关于 **身份迁移** 的警告——从 0.1.x 版本升级时需要手动清空旧身份。
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 首次配置
|
||||
|
||||
1. 打开 **http://localhost:3000/setup** 进入设置向导
|
||||
2. 填写 TeamSpeak 服务器地址(默认端口:9987)
|
||||
3. 设置机器人昵称
|
||||
4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲
|
||||
1. 启动机器人后打开 **http://localhost:3000/**
|
||||
- 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号
|
||||
- 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期)
|
||||
2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写:
|
||||
- TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`)
|
||||
- 端口(默认 9987,自托管或非标准端口请填写实际值)
|
||||
- 机器人昵称
|
||||
- 可选:服务器密码、默认频道
|
||||
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / 酷狗音乐 / B 站账号(可选,登录后可播放 VIP 歌曲、获取每日推荐 / 我的歌单等)
|
||||
4. (可选)在 **设置 → Jellyfin 音乐库** 连接自建 Jellyfin 服务器并打开「启用 Jellyfin 音源」(安装向导第 3 步保存连接时会自动启用;详见 [可选:Jellyfin 音源](#可选jellyfin-音源))
|
||||
5. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
|
||||
|
||||
### WebUI 页面说明
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
|
||||
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
|
||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
|
||||
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达 / 酷狗电台)、我的歌单、收藏的歌单(各源带标签切换);启用 Jellyfin 后另有「Jellyfin 电台 / 最近添加 / 播放最多 / 收藏 / 流派」区块 |
|
||||
| **搜索** | 跨音源统一搜索(仅显示已启用的音源;启用 Jellyfin 后其结果排最前),结果标注来源,可一键收藏歌单 |
|
||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
|
||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||
| **历史** | 播放历史记录 |
|
||||
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
|
||||
| **已存队列** | 保存当前队列为清单、加载(替换)/ 追加 / 删除已保存清单(仅在管理员开启「保存/加载播放清单」后出现) |
|
||||
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停、保存/加载播放清单、单曲直接播放不清空队列) / 多平台账号登录(网易云 / QQ / 酷狗 / B站) / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
|
||||
|
||||
### TeamSpeak 文字命令
|
||||
|
||||
@@ -214,29 +339,70 @@ sudo systemctl start tsmusicbot
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!play <歌名>` | 搜索并播放 |
|
||||
| `!play <歌名>` | 搜索并播放(取最热门的匹配项;默认音源为网易云) |
|
||||
| `!play -n <歌名>` | 显式从网易云音乐搜索(默认音源即网易云,通常可省略) |
|
||||
| `!play -j <歌名>` | 从 Jellyfin 搜索(需先启用 Jellyfin 音源) |
|
||||
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
|
||||
| `!play -k <歌名>` | 从酷狗音乐搜索 |
|
||||
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
|
||||
| `!add <歌名>` | 添加到播放队列 |
|
||||
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
|
||||
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
|
||||
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
|
||||
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
|
||||
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
|
||||
| `!pause` / `!resume` | 暂停 / 恢复播放 |
|
||||
| `!next` / `!prev` | 下一首 / 上一首 |
|
||||
| `!stop` | 停止播放并清空队列 |
|
||||
| `!vol <0-100>` | 设置音量 |
|
||||
| `!queue` | 查看播放队列 |
|
||||
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
|
||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||
| `!playlist <ID>` | 加载歌单 |
|
||||
| `!album <ID>` | 加载专辑 |
|
||||
| `!fm` | 私人 FM(网易云) |
|
||||
| `!lyrics` | 显示当前歌词 |
|
||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
|
||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
|
||||
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
|
||||
| `!fm` | 私人 FM(默认网易云,自动续播) |
|
||||
| `!fm -j` | Jellyfin 电台:从收藏出发的 Instant Mix(需启用 Jellyfin,自动续播) |
|
||||
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
|
||||
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
|
||||
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
|
||||
| `!now` | 当前播放信息 |
|
||||
| `!vote` | 投票跳过当前歌曲 |
|
||||
| `!move <频道名>` | 移动到指定频道 |
|
||||
| `!save <名称>` | 保存当前队列为一份已保存清单(需启用「保存/加载播放清单」,聊天保存进入共享清单) |
|
||||
| `!load [-a] <名称>` | 加载已保存清单(默认替换当前队列并播放;加 `-a` 追加到队列末尾) |
|
||||
| `!queues` | 列出已保存(共享)清单 |
|
||||
| `!help` | 显示帮助信息 |
|
||||
|
||||
> 命令前缀默认为 `!`,可在设置页面修改。支持别名:`!p` = `!play`,`!s` = `!skip`,`!n` = `!next`
|
||||
>
|
||||
> `!save` / `!load` / `!queues` 仅在管理员开启「保存/加载播放清单」后可用(默认关闭),未启用时回复「此功能未启用」。
|
||||
|
||||
### TeamSpeak 命令权限(管理类命令限制)
|
||||
|
||||
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
|
||||
|
||||
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
|
||||
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
|
||||
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
|
||||
|
||||
**配置方式**
|
||||
|
||||
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
|
||||
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
|
||||
|
||||
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限(该命令仅限管理员服务器组)」的提示。
|
||||
|
||||
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
|
||||
|
||||
**如何查看服务器组 ID**
|
||||
|
||||
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
|
||||
|
||||
### 音质等级
|
||||
|
||||
**在线音源(网易云等)**
|
||||
|
||||
| 等级 | 码率 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 标准 | 128kbps | MP3 | 免费可用 |
|
||||
@@ -246,12 +412,33 @@ sudo systemctl start tsmusicbot
|
||||
| Hi-Res | ~1500kbps | FLAC | 需要 VIP |
|
||||
| 超清母带 | ~4000kbps | FLAC | 需要黑胶 VIP |
|
||||
|
||||
**Jellyfin(启用后)**
|
||||
|
||||
| 等级 | 说明 |
|
||||
|------|------|
|
||||
| **原始直传(direct)** | **默认**:原始文件不转码直传(机器人本地统一转 Opus,此档即最高音质) |
|
||||
| 320kbps / 192kbps / 128kbps | 由 Jellyfin 服务器转码后传输,适合公网带宽有限的自建服务器 |
|
||||
|
||||
在设置页面选择音质,立即生效(影响后续播放的歌曲)。
|
||||
|
||||
> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。
|
||||
|
||||
### 重启后保留的播放设置
|
||||
|
||||
以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值:
|
||||
|
||||
| 设置 | 作用范围 | 存储位置 |
|
||||
|------|----------|----------|
|
||||
| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` |
|
||||
| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` |
|
||||
| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` |
|
||||
|
||||
聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
tsmusicbot/
|
||||
teamspeak-music-bot/
|
||||
├── src/ # 后端源码 (TypeScript)
|
||||
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
||||
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
||||
@@ -260,26 +447,38 @@ tsmusicbot/
|
||||
│ ├── bot/ # 机器人核心
|
||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||
│ │ └── manager.ts # 多实例生命周期管理
|
||||
│ │ ├── manager.ts # 多实例生命周期管理
|
||||
│ │ ├── auto-pause.ts # 频道无人自动暂停/恢复的决策逻辑
|
||||
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── config.ts # JSON 配置文件
|
||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
||||
│ │ ├── config.ts # JSON 配置文件(持久化到 data/config.json)
|
||||
│ │ ├── permissions.ts # 细粒度能力 + 按机器人授权白名单
|
||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
|
||||
│ ├── music/ # 音源服务
|
||||
│ │ ├── provider.ts # 统一 MusicProvider 接口
|
||||
│ │ ├── jellyfin.ts # Jellyfin 适配器(可选音源,直连 REST API)
|
||||
│ │ ├── netease.ts # 网易云音乐适配器
|
||||
│ │ ├── qq.ts # QQ 音乐适配器
|
||||
│ │ ├── bilibili.ts # 哔哩哔哩适配器(视频音频提取)
|
||||
│ │ ├── kugou.ts # 酷狗音乐适配器(直连 API,无 npm 依赖 / 无内嵌服务)
|
||||
│ │ ├── youtube.ts # YouTube 适配器(可选,依赖 yt-dlp)
|
||||
│ │ ├── auth.ts # Cookie 持久化存储
|
||||
│ │ └── api-server.ts # 嵌入式 API 服务(自动启动)
|
||||
│ ├── ts-protocol/ # TS3 客户端协议
|
||||
│ │ └── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
|
||||
│ ├── ts-protocol/ # TeamSpeak 客户端协议(TS3/TS6 双协议)
|
||||
│ │ ├── client.ts # 完整客户端(ECDH + AES-EAX 加密协议)
|
||||
│ │ ├── protocol-detect.ts # 服务器协议自动检测(TS3 vs TS6)
|
||||
│ │ ├── http-query.ts # TS6 HTTP Query 客户端(替代 TS3 ServerQuery)
|
||||
│ │ └── ts6-compat.ts # TS6 兼容中间件(版本升级 + 签名)
|
||||
│ ├── web/ # Web 后端
|
||||
│ │ ├── server.ts # Express + WebSocket 服务
|
||||
│ │ ├── websocket.ts # 实时状态广播
|
||||
│ │ ├── middleware/ # requireAuth / requireAdmin / requirePermission / CSRF
|
||||
│ │ └── api/ # REST API 路由
|
||||
│ │ ├── bot.ts # 机器人管理 CRUD
|
||||
│ │ ├── music.ts # 搜索/歌单/歌词/音质
|
||||
│ │ ├── player.ts # 播放控制/队列/历史/跳转
|
||||
│ │ ├── player.ts # 播放控制/队列/历史/跳转/FM
|
||||
│ │ ├── favorites.ts # 本地收藏歌单 CRUD
|
||||
│ │ ├── users.ts # 用户管理 + 成员权限
|
||||
│ │ └── auth.ts # QR登录/Cookie/SMS
|
||||
│ └── index.ts # 入口(启动所有服务)
|
||||
├── web/src/ # 前端源码 (Vue 3)
|
||||
@@ -295,11 +494,11 @@ tsmusicbot/
|
||||
│ └── docker/ # Docker 部署文件
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
├── data/ # 运行时数据(自动创建,不上传)
|
||||
│ ├── tsmusicbot.db # SQLite 数据库
|
||||
│ ├── cookies/ # 登录 Cookie
|
||||
│ └── logs/ # 日志文件
|
||||
└── config.json # 配置文件(首次运行自动生成,不上传)
|
||||
└── data/ # 运行时数据(自动创建,不上传)
|
||||
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
|
||||
├── tsmusicbot.db # SQLite 数据库
|
||||
├── cookies/ # 登录 Cookie
|
||||
└── logs/ # 日志文件
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@@ -310,18 +509,252 @@ tsmusicbot/
|
||||
| **后端框架** | Express 4, WebSocket (ws) |
|
||||
| **数据库** | better-sqlite3 (SQLite) |
|
||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议,兼容 TS3/TS5/TS6) |
|
||||
| **TS 协议** | @honeybbq/teamspeak-client(完整客户端协议)+ 自研 TS6 协议适配层 |
|
||||
| **Jellyfin** | Jellyfin REST API(可选音源,直连,无额外 npm 依赖) |
|
||||
| **网易云 API** | NeteaseCloudMusicApi |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api |
|
||||
| **QQ 音乐 API** | @sansenjian/qq-music-api(锁定 `~2.4.0`,需 Node ≥ 20.17) |
|
||||
| **哔哩哔哩** | BiliBili Web API(搜索、DASH 音频流、QR 登录) |
|
||||
| **酷狗音乐** | 酷狗公开 API(直连,无 npm 依赖 / 无内嵌服务;请求签名 / KRC 歌词解码 / 设备注册移植自 MIT 的 MakcRe/KuGouMusicApi,改用 Node 内置 crypto + zlib) |
|
||||
| **前端框架** | Vue 3, Vite 5, Pinia, Vue Router 4 |
|
||||
| **界面样式** | SCSS(YesPlayMusic 设计风格) |
|
||||
| **图标** | @iconify/vue |
|
||||
| **日志** | pino |
|
||||
|
||||
## 可选:Jellyfin 音源
|
||||
|
||||
本项目可将自建 [Jellyfin](https://jellyfin.org/) 媒体服务器作为**额外音源**:机器人直接播放你自己音乐库里的文件,不依赖任何在线平台的可用性 / 版权 / 登录状态。该音源**默认关闭**,需要手动启用。
|
||||
|
||||
### 启用与连接配置
|
||||
|
||||
三种方式任选:
|
||||
|
||||
1. **首次安装向导** — 第 3 步即 Jellyfin 连接卡(可跳过,稍后配置);填写并「保存并继续」会**自动启用**该音源。
|
||||
2. **WebUI** — 设置 → Jellyfin 音乐库(可选):打开「**启用 Jellyfin 音源**」开关,填写服务器地址、选择认证方式、「测试连接」验证后保存,**保存即时生效,无需重启**。
|
||||
3. **config.json** — 手动编辑 `jellyfin` 配置块,并把 `"jellyfin"` 加入 `enabledProviders`,然后重启。
|
||||
|
||||
两种认证方式:
|
||||
|
||||
| 模式 | 填写内容 | 说明 |
|
||||
|------|---------|------|
|
||||
| **账号密码**(默认) | `username` + `password` | 以该用户身份登录(`AuthenticateByName`),token 自动持久化、失效自动重登 |
|
||||
| **API Key** | `apiKey` + `userId` | 使用管理后台生成的 API Key;`userId` 决定使用谁的音乐库 / 收藏 / 歌单 |
|
||||
|
||||
```jsonc
|
||||
// config.json 片段
|
||||
{
|
||||
"jellyfin": {
|
||||
"serverUrl": "https://jellyfin.example.com",
|
||||
"authMode": "userpass", // 或 "apikey"
|
||||
"username": "music",
|
||||
"password": "······",
|
||||
"apiKey": "", // apikey 模式填写
|
||||
"userId": "" // apikey 模式填写
|
||||
},
|
||||
"enabledProviders": ["netease", "qq", "bilibili", "youtube", "kugou", "jellyfin"]
|
||||
}
|
||||
```
|
||||
|
||||
> 密码 / API Key 在 WebUI 中**只写不回显**;表单留空表示保持已保存的值不变。
|
||||
|
||||
### 功能
|
||||
|
||||
- **搜索** — 歌曲 / 专辑 / 歌单,支持翻页「加载更多」;WebUI 统一搜索中 Jellyfin 结果排最前
|
||||
- **播放** — 懒解析播放地址;默认**原始直传**(不经 Jellyfin 转码),也可选 320/192/128kbps 服务器转码档(设置 → 音质设置)
|
||||
- **歌词** — 读取 Jellyfin 的歌词接口(内嵌或 .lrc),时间轴同步滚动,`!lyrics` 可用
|
||||
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
|
||||
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
|
||||
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
|
||||
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
|
||||
|
||||
### enabledProviders:音源开关
|
||||
|
||||
`config.json` 的 `enabledProviders` 数组决定哪些音源可用(默认 `["netease", "qq", "bilibili", "youtube", "kugou"]`,即在线音源全开、Jellyfin 关闭):
|
||||
|
||||
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
|
||||
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
|
||||
- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
|
||||
- **自定义默认音源(`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 是**可选**的音源,默认**未启用**,需要安装 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 才能使用。启用后可通过聊天命令 `!play -y <关键词>` 或 WebUI 的 YouTube 平台选项搜索/播放 YouTube 视频的音频流。
|
||||
|
||||
### 启用方式(任选其一)
|
||||
|
||||
**方式一:项目本地 `bin/` 目录(推荐)**
|
||||
|
||||
将 `yt-dlp` 可执行文件放到项目根目录下的 `bin/` 文件夹,程序会优先使用此路径。该目录已被 `.gitignore` 忽略,不会影响代码更新。
|
||||
|
||||
```bash
|
||||
# Windows(PowerShell 或 Git Bash)
|
||||
mkdir bin
|
||||
curl -L -o bin/yt-dlp.exe https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
|
||||
|
||||
# Linux / macOS
|
||||
mkdir -p bin
|
||||
curl -L -o bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp
|
||||
chmod +x bin/yt-dlp
|
||||
```
|
||||
|
||||
**方式二:系统级安装(让 `yt-dlp` 在 `PATH` 中可用)**
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
winget install yt-dlp
|
||||
|
||||
# macOS
|
||||
brew install yt-dlp
|
||||
|
||||
# Debian/Ubuntu
|
||||
sudo apt install yt-dlp
|
||||
|
||||
# 通用(Python 环境下)
|
||||
pip install -U yt-dlp
|
||||
```
|
||||
|
||||
### 验证是否可用
|
||||
|
||||
重启机器人程序,在 WebUI 或 `!play -y lofi` 测试搜索。若 `bin/` 和 `PATH` 中都找不到 `yt-dlp`,YouTube 搜索会静默返回空结果(不会影响其他音源),其余功能正常。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- YouTube 音源通过 `yt-dlp` 本地调用实现,不依赖 API Key,也无需登录
|
||||
- 播放的是视频的最佳音频流(`bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio`),由 FFmpeg 解码
|
||||
- 音质由源视频决定,不受音质设置影响
|
||||
- 受 YouTube 风控/地域限制,部分视频可能无法播放
|
||||
- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本
|
||||
|
||||
## Spotify 音源(实验性)
|
||||
|
||||
> **⚠️ 实验性功能,启用前请务必读完本节**
|
||||
>
|
||||
> - **需要 Spotify Premium 账号。** 免费账号无法通过 Spotify Connect 输出音频,无法使用本功能。
|
||||
> - **使用你自己在 Spotify Developer Dashboard 注册的应用(Client ID)。** 本项目**不内置任何共享凭据**,也不会替你代管账号。
|
||||
> - Spotify 官方并未开放第三方播放的公开授权,本功能处于 **Spotify 服务条款的灰色地带**,是否使用请自行评估,**风险自负**。
|
||||
> - 该音源**默认关闭**(`spotify.enabled = false`),需要手动开启并完成授权。
|
||||
> - 这**不是** YouTube 那样的「免登录回退音源」,而是**真正的 Spotify 音频**,必须有 Premium 才能出声;音频链路依赖第三方开源解码器(librespot / go-librespot),本项目仅在本地作为独立子进程调用,**播放效果不做保证**,也未在本仓库端到端测试。
|
||||
|
||||
### 工作原理(简述)
|
||||
|
||||
1. `librespot`(Rust)或 `go-librespot`(Linux)作为**独立子进程**登录 Spotify Connect 并解码音频,输出原始 **PCM**。
|
||||
2. 本项目用内置 **ffmpeg** 把 PCM 重采样到 **48kHz**。
|
||||
3. 重采样后的音频接入**现有的 Opus 编码 / 发送管线**(与其它音源共用),推送到 TeamSpeak。
|
||||
4. 歌名、歌手、封面等**元数据来自 Spotify Web API**。
|
||||
|
||||
### 平台矩阵
|
||||
|
||||
后端由配置项 `spotify.backend` 决定,可选 `auto`(默认)/ `go-librespot` / `librespot`:
|
||||
|
||||
| 平台 | 默认后端(`auto`) | 说明 |
|
||||
|------|------|------|
|
||||
| Windows | `librespot`(Rust) | 不支持 go-librespot(FIFO 仅限 POSIX,且官方无 Windows 资产) |
|
||||
| Linux / Docker | `go-librespot`(可回退 `librespot`) | `auto` 优先 go-librespot,未检测到时自动改用 librespot |
|
||||
| macOS | `librespot`(Rust) | 与 Windows 同,仅支持 Rust 版 |
|
||||
|
||||
> `auto` 会按平台与二进制可用性自动选择:优先 `go-librespot`(若可用),否则 `librespot`。若显式指定 `go-librespot` 或 `librespot` 但对应二进制不存在,则该音源保持不可用。
|
||||
|
||||
### 获取二进制
|
||||
|
||||
程序会先在项目根目录的 `bin/` 中查找,找不到再回退到系统 `PATH`。`bin/` 已被 `.gitignore` 忽略,不影响代码更新。
|
||||
|
||||
**Rust librespot(librespot-org,全平台)** — 官方**没有预编译发布包**,需自行获取(任选其一):
|
||||
|
||||
```bash
|
||||
# 方式 1:用 Cargo 编译安装(需要 Rust 工具链)
|
||||
cargo install librespot
|
||||
|
||||
# 方式 2(Windows):scoop / choco
|
||||
scoop install librespot # 或:choco install librespot
|
||||
|
||||
# 方式 3:把可执行文件放到项目 bin/ 目录
|
||||
# Windows: bin/librespot.exe
|
||||
# Linux/macOS: bin/librespot
|
||||
```
|
||||
|
||||
或直接把 `librespot` 加入系统 `PATH`。
|
||||
|
||||
**go-librespot(仅 Linux)** — 官方仅提供 **Linux** 预编译资产:
|
||||
|
||||
```bash
|
||||
# 从 Release 页下载对应架构的二进制:
|
||||
# https://github.com/devgianlu/go-librespot/releases
|
||||
# 放到项目 bin/ 目录(或加入系统 PATH):
|
||||
# bin/go-librespot
|
||||
```
|
||||
|
||||
> **Windows 不支持 go-librespot**:它依赖 POSIX FIFO(`mkfifo`),官方也只发布 Linux 资产。Windows / macOS 请使用 Rust `librespot`。
|
||||
|
||||
### 注册 Spotify 开发者应用 + 回调地址
|
||||
|
||||
1. 打开 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard),新建一个应用,记下 **Client ID**。
|
||||
2. 在应用设置里添加 **Redirect URI(回调地址)**,精确填写:
|
||||
|
||||
```
|
||||
http://127.0.0.1:<webPort>/api/spotify/callback
|
||||
```
|
||||
|
||||
其中 `<webPort>` 与本项目设置里的 Web 端口一致(默认 `3000`,即 `http://127.0.0.1:3000/api/spotify/callback`)。回调路径必须精确为 `/api/spotify/callback`。
|
||||
3. 本项目使用 **Authorization Code + PKCE** 流程,**不需要 Client Secret**(配置里的 `clientSecret` 可留空)。
|
||||
4. 授权时请求的权限范围(scope):
|
||||
|
||||
```
|
||||
streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private
|
||||
```
|
||||
|
||||
### 启用步骤
|
||||
|
||||
1. 进入**设置页**的「连接 Spotify」卡片。
|
||||
2. 填入 **Client ID**、选择**后端**(`auto` / `go-librespot` / `librespot`)、打开**开关**。
|
||||
3. 点击**保存**。
|
||||
4. 点击「**连接 Spotify**」,在弹出的 Spotify 页面完成 **OAuth 授权**。
|
||||
|
||||
> 仅当 **`enabled = true`** + **已完成 OAuth 授权** + **检测到可用的后端二进制** 三者同时满足时,Spotify 音源才可播放。任一条件不满足,该音源保持**不可用**(点播会被跳过,播放队列照常前进,不影响其它音源)。
|
||||
|
||||
对应的配置块(`data/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"spotify": {
|
||||
"enabled": false,
|
||||
"backend": "auto",
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"deviceName": "TSMusicBot",
|
||||
"bitrate": 320
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OAuth 相关端点:`/api/spotify/login`、`/api/spotify/callback`、`/api/spotify/status`。
|
||||
|
||||
### 许可与来源
|
||||
|
||||
- **go-librespot** 采用 **GPL-3.0** 许可。本项目**仅将其作为独立子进程调用**(mere aggregation / 独立聚合),**不链接、不打包**其代码,因此**不影响本项目自身的 MIT 许可**。
|
||||
- 源码与许可:<https://github.com/devgianlu/go-librespot>(GPL-3.0;如需其对应源码请前往该仓库获取 —— source offer)。
|
||||
- **Rust librespot** 采用 **MIT** 许可:<https://github.com/librespot-org/librespot>。
|
||||
|
||||
### 故障排查
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 提示「未检测到 librespot / go-librespot」 | 检查项目 `bin/` 目录里是否放了可执行文件,或该命令是否在系统 `PATH` 中;Rust 版可用 `cargo install librespot` 安装 |
|
||||
| 提示「未授权 / 需要连接 Spotify」 | 在设置页「连接 Spotify」卡片点击「连接 Spotify」完成 OAuth 授权 |
|
||||
| Windows 上无法使用 go-librespot | 属预期行为(FIFO 仅限 POSIX,官方无 Windows 资产);请把 `backend` 设为 `librespot` 或 `auto` |
|
||||
| 完全没有声音 | 确认账号为 **Spotify Premium**;免费账号无法通过 Spotify Connect 输出音频 |
|
||||
|
||||
### 已知限制
|
||||
|
||||
- 在多租户/共享主机上,librespot 通过命令行参数接收访问令牌,同机其他本地进程理论上可读取(令牌约 1 小时有效,需本地访问权限)。
|
||||
- Spotify 连续播放(gapless spotify→spotify)时,网页进度条的"已播放时间"可能不准确(以后端上报的播放进度为准)。
|
||||
- 运行多个启用 Spotify 的 bot 时,若两个 bot 的 id 端口哈希发生冲突(同一 % 1000 桶),第二个 go-librespot 边车会因端口占用而启动失败、该 bot 的 Spotify 不可用(后续将改为按需分配空闲端口)。
|
||||
- **一个 Spotify Premium 账号只支持「一路」正在播放的音频流**。因此若要同时运行**多个**启用 Spotify 的 bot 并让它们各自独立播放,必须为**每个 bot 配置独立的 Spotify 账号**。在 Rust(librespot)后端上,本机制已把播放控制(暂停/继续/跳转)限定到 bot 自己的设备,并在读取播放状态时忽略其它设备的状态,以避免多 bot 之间互相抢占、来回抖动(cross-control/thrash);但受 Spotify 平台限制,**共用同一账号无法实现多路同时播放**(第二个 bot 开始播放会夺走该账号唯一的活跃会话)。go-librespot 后端为每个边车独立的本地 REST API,不受此账号级抢占影响。
|
||||
|
||||
## 配置文件
|
||||
|
||||
`config.json` 在首次运行时自动生成,可手动编辑:
|
||||
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -335,18 +768,41 @@ tsmusicbot/
|
||||
"adminPassword": "",
|
||||
"adminGroups": [],
|
||||
"autoReturnDelay": 300,
|
||||
"autoPauseOnEmpty": true
|
||||
"autoPauseOnEmpty": false,
|
||||
"idleTimeoutMinutes": 0,
|
||||
"publicUrl": "",
|
||||
"trustProxy": false
|
||||
}
|
||||
```
|
||||
|
||||
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
|
||||
|
||||
> **关于 `adminPassword` 和 `adminGroups`**:`adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制),详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制)。`adminPassword` 仍为旧版预留字段、当前版本未使用——WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
|
||||
|
||||
### 反向代理部署注意事项
|
||||
|
||||
当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`:
|
||||
|
||||
- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。
|
||||
- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。
|
||||
- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。
|
||||
|
||||
直接暴露端口(无代理)时无需启用该选项。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:支持 TeamSpeak 6 Server 吗?**
|
||||
A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测服务器类型。如果自动检测失败(例如 Query 端口被防火墙屏蔽),可以在创建机器人时手动指定 `serverProtocol: "ts6"`。TS6 Server 的 HTTP Query API(端口 10080)也已适配,需要时可配置 `ts6ApiKey`。
|
||||
|
||||
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
|
||||
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
|
||||
|
||||
**Q:提示"无法获取播放链接"?**
|
||||
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
|
||||
|
||||
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
|
||||
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
|
||||
|
||||
**Q:如何更换机器人所在频道?**
|
||||
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
|
||||
|
||||
@@ -354,7 +810,16 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
|
||||
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
|
||||
|
||||
**Q:端口 3200 被占用?**
|
||||
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。
|
||||
A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
|
||||
|
||||
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
|
||||
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
|
||||
|
||||
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
|
||||
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
|
||||
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
|
||||
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
|
||||
修好版本后重新 `npm install && npm run build` 并重启即可。
|
||||
|
||||
**Q:播放歌曲时报 FFmpeg EACCES 错误?**
|
||||
A:`ffmpeg-static` 内置的 FFmpeg 二进制文件缺少执行权限。程序已自动尝试修复,如果仍然失败,请手动执行:
|
||||
@@ -369,9 +834,36 @@ A:原生模块(opus、sqlite3)需要编译工具,Dockerfile 已包含。
|
||||
**Q:B站视频搜索不到结果?**
|
||||
A:B站搜索需要 buvid3 匿名 Cookie(程序启动时自动获取)。如果失败,重启程序即可。登录B站账号后搜索效果更好。
|
||||
|
||||
**Q:YouTube 平台搜索返回空结果?**
|
||||
A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:YouTube 音源](#可选youtube-音源) 章节。快速验证:在项目根目录执行 `bin/yt-dlp --version`(或系统 `yt-dlp --version`),能打印版本号即可。若 yt-dlp 已安装但仍搜索失败,通常是网络/地域问题或 yt-dlp 版本过旧(执行 `yt-dlp -U` 升级)。
|
||||
|
||||
**Q:如何更新到新版本?**
|
||||
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
|
||||
|
||||
**Q:忘记管理员密码怎么办?**
|
||||
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
||||
|
||||
**Q:成员(member)能做什么?不能做什么?**
|
||||
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
|
||||
|
||||
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
|
||||
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
|
||||
|
||||
**Q:什么是"专属链接"?怎么用?**
|
||||
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
|
||||
|
||||
**Q:机器人播放时突然自动暂停了?**
|
||||
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
|
||||
|
||||
**Q:如何把某个用户从成员升级为管理员?**
|
||||
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
|
||||
|
||||
**Q:登录之后多久会自动退出?**
|
||||
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
|
||||
|
||||
**Q:部署到公网后如何防止暴力登录?**
|
||||
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
1. Fork 本仓库
|
||||
@@ -380,12 +872,144 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
||||
4. 推送分支 (`git push origin feature/新功能`)
|
||||
5. 提交 Pull Request
|
||||
|
||||
## 更新日志
|
||||
|
||||
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
|
||||
|
||||
### 最新版本 — 保存/加载播放清单 + 重启恢复队列
|
||||
|
||||
**保存/加载播放清单 + 队列持久化([#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 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
|
||||
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
|
||||
- **本地音频上传播放**:搜索页支持拖拽 / 选择本地音频上传(保存到 `data/local-audio`),上传后可像普通歌曲一样播放、下一首播放或加入队列;设置 → 行为设置 中新增「本地音频播放」开关,关闭后拒绝新的本地上传和本地歌曲播放请求。播放结束或停止 / 清空 / 替换队列时会从服务端删除已接收文件并更新索引。
|
||||
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
|
||||
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
|
||||
- **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 修复**
|
||||
|
||||
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
|
||||
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
|
||||
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
|
||||
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
|
||||
|
||||
**WebUI 鉴权与权限系统**
|
||||
|
||||
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
|
||||
- **两种角色:admin / member**。`member` 可以管理机器人、控制播放、登录音乐平台账号、修改自己密码,但不能管理其他用户或查看审计日志。`admin` 拥有全部权限。
|
||||
- **用户管理 UI**:管理员在 设置 → 用户管理 可以增删用户、切换角色、重置密码。系统强制保留至少一位管理员。
|
||||
- **操作审计日志**:管理员在 设置 → 操作审计 可以查看用户管理相关事件(创建、删除、密码重置、角色变更、首位管理员创建、自助修改密码)。
|
||||
- **自助修改密码**:所有用户都可在 设置 → 账户 修改自己密码。
|
||||
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
|
||||
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
|
||||
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
|
||||
- **搜索引擎隐身(防止实例被收录,issue #128)**:为避免部署实例的 WebUI 被搜索引擎收录、被陌生人搜到控制页,采用纵深防御——所有响应携带 `X-Robots-Tag: noindex, nofollow`,`/robots.txt` 返回 `User-agent: * / Disallow: /`,`index.html` 内置 `<meta name="robots" content="noindex, nofollow">`(专属链接 `/bot/<id>` 等所有页面同样覆盖)。这些只阻止「被索引」,不是访问控制——**请不要把自己的 WebUI 链接发到公开网页 / 论坛 / 聊天群**,真正的防护来自登录鉴权与反向代理。
|
||||
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
|
||||
|
||||
### v0.x — Bot Profile 自动更新与协议层升级
|
||||
|
||||
**机器人形象自动更新(Bot Profile)**
|
||||
|
||||
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
||||
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
|
||||
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
|
||||
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
|
||||
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
||||
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
|
||||
|
||||
**新命令 & FM 修复**
|
||||
|
||||
- **新增 `!artist <歌手名>` 命令**:搜索指定歌手的歌曲并循环播放,支持 `-q`(QQ 音乐)/ `-b`(B站)/ `-y`(YouTube)平台切换。一次加载最多 50 首,自动按歌手名过滤并设为 Loop 模式。
|
||||
- **歌单模糊搜索**:`!playlist` 现在支持歌单名称模糊搜索(如 `!playlist 华语经典`),自动匹配公开歌单 + 个人歌单(网易云 + QQ)。纯数字 ID 和 URL 解析保持兼容。
|
||||
- **修复 `!fm` 播放中断**:私人 FM 几首歌后静音的 bug 已修复。新增自动续播机制(队列低位自动拉取新歌),播放器健康帧追踪防止临时 URL 失败导致永久静音。
|
||||
- **QQ 音乐个人歌单**:QQ Music provider 新增 `getUserPlaylists` 支持,登录后可通过 `!playlist -q <名称>` 模糊搜索个人歌单。
|
||||
|
||||
**协议层 & 稳定性**
|
||||
|
||||
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
|
||||
- ⚠️ **破坏性**:`0.1.0` 生成的旧身份与新握手路径不兼容,升级时需要迁移。详见 [更新升级](#更新升级) 章节顶部的警告。
|
||||
- **修复 `startBot` 与 `stopBot` 之间的竞态**:mid-handshake 被替换的 BotInstance 不再泄漏 TS 会话,`disconnect()` 被 `connect()` 的 await 插队时不再错误地把 `connected` 翻回 `true`。
|
||||
- **修复播放条自动刷新 bug**:BotManager 现在在创建新 BotInstance 时 emit `botInstance` 事件,WebSocket 监听器会立即重新挂接到新实例,播放状态变化无需手动刷新页面。
|
||||
- **`connect()` 增加 15 秒超时**:握手卡住时会清理掉挂起的实例并返回 500,不再无限阻塞 HTTP 请求和 UI。
|
||||
- **识别持久化修复**:`startBot` 现在会从数据库读取 `identity` 传给新 BotInstance,服务器组在机器人重启后能保留。
|
||||
|
||||
**HTTP API 加固**
|
||||
|
||||
- 新增输入校验,拒绝无效值并返回 **400**(之前会返回 200 包装 usage-text 字符串):
|
||||
- `/volume`:非数字、`NaN`/`Infinity`、超出 `[0,100]`
|
||||
- `/mode`:不在 `{seq, loop, random, rloop}` 中的值
|
||||
- `/seek`:`NaN`/`Infinity`、负数、字符串
|
||||
- `/play-at`:索引越界(**先**校验再停止当前播放,避免误杀正在播的歌)
|
||||
- **修复 YouTube 平台路由**:`/play`、`/add`、`/playlist`、`/play-by-id`、`/add-by-id`、`/play-playlist` 现在都正确处理 `platform=youtube`(之前会静默回退到网易云)。
|
||||
- **修复 `/auth/status?platform=youtube` 数据泄漏**:之前会回退到网易云并返回网易云用户的昵称 + 头像 URL,现在正确路由到 YouTube provider 并报告 `yt-dlp` 的实际可用状态。
|
||||
- **`/auth/cookie` 拒绝 `platform=youtube`**,防止意外覆盖网易云 cookie。
|
||||
|
||||
**连接状态一致性**
|
||||
|
||||
- 断开连接时,音频命令(`play`/`add`/`next`/`prev`/`playlist`/`album`/`fm`)返回 **400 "Bot is not connected to TeamSpeak"**;配置类命令(`volume`/`mode`/`clear`/`stop`/`queue`/`now`/`lyrics`)仍可正常工作,保持 UI 可用。
|
||||
- `resolveAndPlay` 在网络请求(URL 解析)前后都会检查 `this.connected`,防止在解析期间被 `stop()` 中断后仍然启动 ffmpeg。
|
||||
- `tsClient` 的 `disconnected` 事件处理器现在总是清理播放器状态,不再因为 `connect()` 从未完成而遗留 `playing=true` 的僵尸状态。
|
||||
|
||||
**功能改进**
|
||||
|
||||
- **YouTube 音源(可选)**:新增基于 `yt-dlp` 的 YouTube provider,通过 `!play -y <关键词>` 或 WebUI 平台选项使用。未安装 `yt-dlp` 时静默降级、返回空结果,不影响其他音源。详见 [可选:YouTube 音源](#可选youtube-音源)。
|
||||
- **Bot Selector UI**:
|
||||
- 始终可见(不再只有 ≥2 个机器人时才显示)
|
||||
- 尺寸放大(更大的按钮、字体、状态图标)
|
||||
- 每行增加 **电源按键**(一键启动/停止对应机器人,带禁用态与播放状态高亮)
|
||||
- 每行增加 **链接按钮**(复制机器人专属 URL)
|
||||
- 新路由 `/bot/:id`,打开后自动切换到对应机器人
|
||||
- **服务器密码登录**:`serverPassword` 字段已加入数据库与 Settings UI,支持加入需要密码的 TS 服务器。
|
||||
- **`!add` 一键开播**:在连接状态下向空队列 `!add` 歌曲时自动开始播放(之前只会入队,需要再 `!play` 或 `!next`)。
|
||||
- **WebSocket 新增 `botRemoved` 事件**:删除机器人后 UI 会立即从列表中移除(之前需要手动刷新页面)。
|
||||
|
||||
**内部修复**
|
||||
|
||||
- **`PlayQueue.remove()` 当前歌曲移除 bug**:移除正在播放的歌曲时,`next()` 不再跳过紧跟其后的那首歌。
|
||||
- **投票跳过**:需要的票数现在至少为 1(避免 `needed=0` 时单人"全票通过"的边界情况);投票计数会在每首新歌开始时自动清零,不再跨歌曲泄漏。
|
||||
- 多处输入边界修复:`seek` 防止 `NaN` 毒化 `seekOffset` 导致 `getElapsed()` 永久返回 `NaN`;`play-at` 越界时不再误杀当前播放。
|
||||
|
||||
### 历史重要变更
|
||||
|
||||
更早的变更请查阅 git log。主要里程碑:
|
||||
|
||||
- **初始 TS3/TS6 双协议支持**:自动协议检测(TS3 port 10011 vs TS6 port 10080)、TS6 HTTP Query 客户端、数据库持久化 `serverProtocol` / `ts6ApiKey`。
|
||||
- **多机器人架构**:支持同一进程中运行多个机器人实例,独立队列、进度、音量;WebUI 一键切换。
|
||||
- **网易云 / QQ 音乐 / 哔哩哔哩**:三平台原生音源,QR 码登录,Cookie 持久化。
|
||||
- **酷狗音乐音源**:第四个原生音源(直连 API,无 npm 依赖 / 无内嵌服务),覆盖搜索 / 播放 / KRC 歌词 / 专辑 / QR 登录,登录后支持每日推荐 / 推荐歌单 / 我的歌单 / 私人电台与歌曲封面。
|
||||
- **Docker & systemd 部署**:一键部署脚本,数据卷持久化,自动重启支持。
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下项目和开发者:
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| [Jellyfin](https://github.com/jellyfin/jellyfin) | 自由软件媒体服务器(本项目的可选自建音源) |
|
||||
| [ItsEricRao](https://github.com/ItsEricRao) | Jellyfin 音源集成贡献者([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123)) |
|
||||
| [yichen11818/NeteaseTSBot](https://github.com/yichen11818/NeteaseTSBot) | TS6 协议兼容参考(vendored tsproto 补丁) |
|
||||
| [Splamy/TS3AudioBot](https://github.com/Splamy/TS3AudioBot) | 优秀的 TeamSpeak 音频机器人框架 |
|
||||
| [TS3AudioBot-BiliBiliPlugin](https://github.com/xxmod/TS3AudioBot-BiliBiliPlugin) | 提供插件开发参考 |
|
||||
| [TS3AudioBot-NetEaseCloudmusic-plugin](https://github.com/ZHANGTIANYAO1/TS3AudioBot-NetEaseCloudmusic-plugin) | 提供插件开发参考和懒加载设计参考 |
|
||||
@@ -397,6 +1021,7 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
||||
| [@sansenjian/qq-music-api](https://github.com/sansenjian/qq-music-api) | QQ 音乐 API 活跃维护版本 |
|
||||
| [@honeybbq/teamspeak-client](https://www.npmjs.com/package/@honeybbq/teamspeak-client) | TS3 完整客户端协议实现 |
|
||||
| [bilibili-API-collect](https://github.com/SocialSisterYi/bilibili-API-collect) | 哔哩哔哩 API 文档 |
|
||||
| [MakcRe/KuGouMusicApi](https://github.com/MakcRe/KuGouMusicApi) | 酷狗音乐 API 参考(请求签名 / KRC 歌词解码 / 设备注册移植来源,MIT 许可) |
|
||||
|
||||
## 开源许可
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
# FM Bug Fix + Artist Loop + Playlist Fuzzy Search — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix FM audio dropout bug, add `!artist` command for artist-based loop playback, and support playlist name fuzzy search in `!playlist`.
|
||||
|
||||
**Architecture:** All changes stay within existing files. The FM fix adds auto-refill logic and a success-tracking mechanism in the player. Playlist search reuses the existing `provider.search()` API that already returns playlists. The `!artist` command is a new command method following the same pattern as `cmdPlay`/`cmdFm`.
|
||||
|
||||
**Tech Stack:** TypeScript, Node.js, ffmpeg-static, @honeybbq/teamspeak-client
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/bot/instance.ts` | Modify | Add `isFmMode`, `refillFm()`, fix `cmdFm()`, modify `cmdPlaylist()`, add `cmdArtist()`, modify `playNext()` to trigger FM refill |
|
||||
| `src/bot/commands.ts` | Modify | Register `artist` in PUBLIC_COMMANDS, update help text |
|
||||
| `src/audio/player.ts` | Modify | Track healthy frame count, reset `consecutiveFailures` after sustained successful playback |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix FM — Track healthy playback in AudioPlayer
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/audio/player.ts:62-82` (add field)
|
||||
- Modify: `src/audio/player.ts:243-261` (sendNextFrame — track healthy frames)
|
||||
|
||||
- [ ] **Step 1: Add healthy frame counter field**
|
||||
|
||||
In `src/audio/player.ts`, after the `consecutiveFailures` field (line ~80), add:
|
||||
|
||||
```typescript
|
||||
private healthyFrames = 0;
|
||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Track healthy frames and reset failures in sendNextFrame**
|
||||
|
||||
In `src/audio/player.ts`, in the `sendNextFrame()` method, after line 257 (`this.framesPlayed++;`), add:
|
||||
|
||||
```typescript
|
||||
this.healthyFrames++;
|
||||
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.healthyFrames = 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reset healthyFrames in play() and stop()**
|
||||
|
||||
In `play()`, after `this.framesPlayed = 0;` (line ~95), add:
|
||||
|
||||
```typescript
|
||||
this.healthyFrames = 0;
|
||||
```
|
||||
|
||||
In `stop()`, after `this.framesPlayed = 0;` (line ~181), add:
|
||||
|
||||
```typescript
|
||||
this.healthyFrames = 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/audio/player.ts
|
||||
git commit -m "fix(player): reset consecutiveFailures after sustained healthy playback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix FM — Add auto-refill logic in BotInstance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/instance.ts:62-66` (add fields)
|
||||
- Modify: `src/bot/instance.ts:557-573` (cmdFm)
|
||||
- Modify: `src/bot/instance.ts:642-673` (playNext — add refill trigger)
|
||||
|
||||
- [ ] **Step 1: Add isFmMode field**
|
||||
|
||||
In `src/bot/instance.ts`, after `private profileManager: BotProfileManager;` (line ~66), add:
|
||||
|
||||
```typescript
|
||||
private isFmMode = false;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add refillFm method**
|
||||
|
||||
In `src/bot/instance.ts`, before the `cmdVote` method (after `cmdFm`'s closing brace), add:
|
||||
|
||||
```typescript
|
||||
private async refillFm(): Promise<void> {
|
||||
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
||||
try {
|
||||
const songs = await this.neteaseProvider.getPersonalFm();
|
||||
if (songs.length === 0) return;
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: "netease" });
|
||||
}
|
||||
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, "Failed to refill FM queue");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Modify cmdFm to set isFmMode and use RandomLoop**
|
||||
|
||||
Replace the existing `cmdFm` method (lines 557-573) with:
|
||||
|
||||
```typescript
|
||||
private async cmdFm(): Promise<string> {
|
||||
if (!this.neteaseProvider.getPersonalFm) {
|
||||
return "Personal FM is only available for NetEase Cloud Music";
|
||||
}
|
||||
const songs = await this.neteaseProvider.getPersonalFm();
|
||||
if (songs.length === 0)
|
||||
return "No FM songs available (need to login first)";
|
||||
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: "netease" });
|
||||
}
|
||||
this.queue.setMode(PlayMode.RandomLoop);
|
||||
this.isFmMode = true;
|
||||
this.player.resetFailures();
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.emit("stateChange");
|
||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Modify playNext to trigger FM refill and check isFmMode**
|
||||
|
||||
In `playNext()`, replace the `else` branch (lines 665-668) that handles `queue.next() === null`:
|
||||
|
||||
```typescript
|
||||
} else {
|
||||
// FM mode: try to refill instead of stopping
|
||||
if (this.isFmMode) {
|
||||
await this.refillFm();
|
||||
const refillNext = this.queue.next();
|
||||
if (refillNext) {
|
||||
const started = await this.resolveAndPlay(refillNext);
|
||||
if (!started) {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
this.emit("stateChange");
|
||||
} else {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also add a proactive refill after successful advance. At the end of the `if (next)` block, after `this.emit("stateChange");` is handled outside the if/else, add this right after `resolveAndPlay` succeeds (inside the `if (next)` block, after the retry loop):
|
||||
|
||||
After the `if (!started)` block and before the closing `}` of `if (next)`, insert:
|
||||
|
||||
```typescript
|
||||
// Proactive FM refill when running low
|
||||
if (this.isFmMode && this.queue.size() - (this.queue.getCurrentIndex()) <= 3) {
|
||||
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
|
||||
}
|
||||
```
|
||||
|
||||
Wait — `this.emit("stateChange")` is outside the `if (next)` block. Let me re-read the original code structure...
|
||||
|
||||
The original `playNext()` structure is:
|
||||
```
|
||||
if (next) {
|
||||
let started = await resolveAndPlay(next)
|
||||
if (!started) { retry loop... }
|
||||
if (!started) { stop }
|
||||
} else {
|
||||
stop
|
||||
}
|
||||
emit("stateChange")
|
||||
```
|
||||
|
||||
So I need to add the proactive refill inside the `if (next)` block, right after `resolveAndPlay` succeeds. Let me write this more carefully:
|
||||
|
||||
```typescript
|
||||
private async playNext(): Promise<void> {
|
||||
if (this.isAdvancing || !this.connected) return;
|
||||
this.isAdvancing = true;
|
||||
try {
|
||||
this.voteSkipUsers.clear();
|
||||
const next = this.queue.next();
|
||||
if (next) {
|
||||
let started = await this.resolveAndPlay(next);
|
||||
if (!started) {
|
||||
for (let i = 0; i < 3 && this.connected; i++) {
|
||||
const retry = this.queue.next();
|
||||
if (!retry) break;
|
||||
if (await this.resolveAndPlay(retry)) {
|
||||
started = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
|
||||
// Proactive refill: when queue is running low, fetch more FM songs
|
||||
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
|
||||
}
|
||||
} else {
|
||||
// Queue exhausted — in FM mode, refill instead of stopping
|
||||
if (this.isFmMode) {
|
||||
await this.refillFm();
|
||||
const refillNext = this.queue.next();
|
||||
if (refillNext) {
|
||||
await this.resolveAndPlay(refillNext);
|
||||
} else {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
this.player.stop();
|
||||
this.profileManager.onSongChange(null).catch(() => {});
|
||||
}
|
||||
}
|
||||
this.emit("stateChange");
|
||||
} finally {
|
||||
this.isAdvancing = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OK this is getting complex. Let me simplify the plan — I'll structure it more clearly.
|
||||
|
||||
Also I need to clear `isFmMode` when user issues stop/clear or manually plays something else.
|
||||
|
||||
- [ ] **Step 5: Clear isFmMode in stop/clear/play commands**
|
||||
|
||||
In `cmdStop()`, after `this.queue.clear();`, add:
|
||||
|
||||
```typescript
|
||||
this.isFmMode = false;
|
||||
```
|
||||
|
||||
In `cmdClear()` (same line), add:
|
||||
|
||||
```typescript
|
||||
this.isFmMode = false;
|
||||
```
|
||||
|
||||
In `cmdPlay()`, after `this.queue.clear();`, add:
|
||||
|
||||
```typescript
|
||||
this.isFmMode = false;
|
||||
```
|
||||
|
||||
In `cmdPlaylist()`, after `this.queue.clear();`, add:
|
||||
|
||||
```typescript
|
||||
this.isFmMode = false;
|
||||
```
|
||||
|
||||
In `cmdAlbum()`, after `this.queue.clear();`, add:
|
||||
|
||||
```typescript
|
||||
this.isFmMode = false;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/instance.ts
|
||||
git commit -m "fix: FM auto-refill to prevent audio dropout after initial batch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Playlist Fuzzy Search
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/instance.ts:524-539` (cmdPlaylist)
|
||||
|
||||
- [ ] **Step 1: Modify cmdPlaylist to support name search**
|
||||
|
||||
Replace the `cmdPlaylist` method (lines 524-539) with:
|
||||
|
||||
```typescript
|
||||
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
|
||||
// Determine if input is a numeric ID or a name search
|
||||
const id = this.extractId(cmd.args);
|
||||
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||
|
||||
let playlistId: string;
|
||||
|
||||
if (isNumericId || id !== cmd.args) {
|
||||
// Input is a numeric ID or URL containing an ID — use existing logic
|
||||
playlistId = id;
|
||||
} else {
|
||||
// Name-based search
|
||||
const result = await provider.search(cmd.args);
|
||||
let playlists = result.playlists ?? [];
|
||||
|
||||
// Also search user's personal playlists if logged in
|
||||
if (provider.getUserPlaylists) {
|
||||
try {
|
||||
const userPlaylists = await provider.getUserPlaylists();
|
||||
const query = cmd.args.toLowerCase();
|
||||
const matched = userPlaylists.filter(
|
||||
p => p.name.toLowerCase().includes(query)
|
||||
);
|
||||
// Merge: public results first (API-ranked), then user matches
|
||||
playlists = [...playlists, ...matched];
|
||||
} catch {
|
||||
// User playlists unavailable — continue with public results
|
||||
}
|
||||
}
|
||||
|
||||
if (playlists.length === 0)
|
||||
return `No playlists found for: ${cmd.args}`;
|
||||
playlistId = playlists[0].id;
|
||||
}
|
||||
|
||||
const songs = await provider.getPlaylistSongs(playlistId);
|
||||
if (songs.length === 0) return "Playlist is empty or not found";
|
||||
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
for (const song of songs) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.emit("stateChange");
|
||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update help text to reflect new usage**
|
||||
|
||||
In `cmdHelp()` (line ~633), change the playlist line from:
|
||||
|
||||
```
|
||||
`${p}playlist <id> — Load playlist`
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```
|
||||
`${p}playlist <name or id> — Load playlist by name or ID`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/instance.ts
|
||||
git commit -m "feat: support playlist name fuzzy search in !playlist command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Artist Loop Command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/commands.ts:8-11` (PUBLIC_COMMANDS)
|
||||
- Modify: `src/bot/commands.ts:248-260` (AUDIO_COMMANDS in instance.ts — actually in instance.ts)
|
||||
- Modify: `src/bot/instance.ts:244-314` (executeCommand switch + add cmdArtist)
|
||||
|
||||
Wait, AUDIO_COMMANDS is in instance.ts executeCommand. Let me check...
|
||||
|
||||
Actually looking back at instance.ts, the AUDIO_COMMANDS set is local to executeCommand. I don't need to add artist there since it will be handled in the switch.
|
||||
|
||||
- [ ] **Step 1: Register `artist` in PUBLIC_COMMANDS**
|
||||
|
||||
In `src/bot/commands.ts`, line 9, add `"artist"` to the PUBLIC_COMMANDS set:
|
||||
|
||||
```typescript
|
||||
export const PUBLIC_COMMANDS = new Set([
|
||||
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
||||
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
||||
"artist",
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `artist` to the AUDIO_COMMANDS set in executeCommand**
|
||||
|
||||
In `src/bot/instance.ts`, in the `executeCommand` method, add `"artist"` to the AUDIO_COMMANDS set (line ~253):
|
||||
|
||||
```typescript
|
||||
const AUDIO_COMMANDS = new Set([
|
||||
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
|
||||
"artist",
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `artist` case to the switch in executeCommand**
|
||||
|
||||
In `src/bot/instance.ts`, after the `case "fm":` block (line ~300), add:
|
||||
|
||||
```typescript
|
||||
case "artist":
|
||||
return this.cmdArtist(cmd);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement cmdArtist method**
|
||||
|
||||
Add the `cmdArtist` method in `src/bot/instance.ts`, after `cmdFm()`:
|
||||
|
||||
```typescript
|
||||
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return "Usage: !artist <artist name>";
|
||||
const provider = this.getProvider(cmd.flags);
|
||||
const result = await provider.search(cmd.args, 50);
|
||||
if (result.songs.length === 0)
|
||||
return `No results found for artist: ${cmd.args}`;
|
||||
|
||||
const query = cmd.args.toLowerCase();
|
||||
let filtered = result.songs.filter(
|
||||
s => s.artist.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
// Fallback to unfiltered results if filtering drops everything
|
||||
if (filtered.length === 0) {
|
||||
filtered = result.songs.slice(0, 20);
|
||||
}
|
||||
|
||||
this.queue.clear();
|
||||
this.isFmMode = false;
|
||||
for (const song of filtered) {
|
||||
this.queue.add({ ...song, platform: provider.platform });
|
||||
}
|
||||
this.queue.setMode(PlayMode.Loop);
|
||||
this.player.resetFailures();
|
||||
|
||||
const first = this.queue.play();
|
||||
if (first) await this.resolveAndPlay(first);
|
||||
this.emit("stateChange");
|
||||
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update help text**
|
||||
|
||||
In `cmdHelp()`, add the artist help line after the fm line:
|
||||
|
||||
```
|
||||
`${p}artist <name> — Play songs by artist (loop)`
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/commands.ts src/bot/instance.ts
|
||||
git commit -m "feat: add !artist command for artist-based loop playback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Type-check and verify
|
||||
|
||||
**Files:**
|
||||
- All modified files
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
|
||||
```bash
|
||||
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
|
||||
```
|
||||
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 2: Verify command parsing**
|
||||
|
||||
```bash
|
||||
cd /home/proxxy/project/teamspeak-music-bot && node --loader ts-node/esm -e "
|
||||
const { parseCommand } = await import('./src/bot/commands.ts');
|
||||
console.log(parseCommand('!artist 周杰伦', '!'));
|
||||
console.log(parseCommand('!artist 周杰伦 -q', '!'));
|
||||
console.log(parseCommand('!playlist 华语经典', '!'));
|
||||
console.log(parseCommand('!playlist 123456', '!'));
|
||||
"
|
||||
```
|
||||
|
||||
Expected: All parse correctly; `artist` with args "周杰伦", `playlist` with args "华语经典" and "123456".
|
||||
|
||||
- [ ] **Step 3: Commit any fixes from type check**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore: type fixes from final verification"
|
||||
```
|
||||
(Only if there were issues)
|
||||
|
||||
---
|
||||
|
||||
### Self-Review Checklist
|
||||
|
||||
1. **Spec coverage:**
|
||||
- FM bug fix → Tasks 1, 2 (healthy frame tracking + auto-refill)
|
||||
- Playlist fuzzy search → Task 3
|
||||
- Artist loop → Task 4
|
||||
- Verification → Task 5
|
||||
|
||||
2. **No placeholders** — all steps have exact code.
|
||||
|
||||
3. **Type consistency:**
|
||||
- `isFmMode: boolean` — used in cmdFm, cmdStop, cmdClear, cmdPlay, cmdPlaylist, cmdAlbum, playNext, refillFm ✓
|
||||
- `refillFm(): Promise<void>` — called from cmdFm (indirectly via playNext trigger), playNext ✓
|
||||
- `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓
|
||||
@@ -0,0 +1,911 @@
|
||||
# Music Source Tabs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add NetEase / QQ source-switcher tabs to Home (推荐歌单 / 每日推荐 / 我的歌单) and Library (我的歌单), with per-section persistence and graceful degradation when only one source is logged in.
|
||||
|
||||
**Architecture:** A single shared `<SourceTabs>` Vue component handles the tab UI and self-hides when fewer than 2 sources are available. The Pinia store splits the affected fields into `{ netease, qq }` objects, fetches from both platforms in `fetchHomeData()` based on `authStatus`, and consumers select with a reactive `activeSource` ref persisted to localStorage.
|
||||
|
||||
**Tech Stack:** Vue 3 (Composition API + `<script setup>`), Pinia, TypeScript, SCSS (CSS variables from `web/src/styles/variables.scss`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-06-music-source-tabs-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New:**
|
||||
- `web/src/components/SourceTabs.vue` — shared tab UI (presentational, no store deps)
|
||||
|
||||
**Modified:**
|
||||
- `web/src/stores/player.ts` — state shape change + `authStatus` + `fetchHomeData` rewrite
|
||||
- `web/src/views/Home.vue` — 3 sections wired to SourceTabs
|
||||
- `web/src/views/Library.vue` — 1 section wired; remove dead `liked` block
|
||||
|
||||
**Unchanged:**
|
||||
- Backend (already supports `?platform=qq`)
|
||||
- All other web pages
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Build the SourceTabs component
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/SourceTabs.vue`
|
||||
|
||||
This task is fully independent of store changes — the component is presentational, takes typed props, and emits an update event. It can land and be committed alone (build will pass; component is just unused until later tasks).
|
||||
|
||||
- [ ] **Step 1.1: Create the component file**
|
||||
|
||||
Write `web/src/components/SourceTabs.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="sources.length >= 2" class="source-tabs">
|
||||
<button
|
||||
v-for="src in sources"
|
||||
:key="src"
|
||||
type="button"
|
||||
class="source-tab"
|
||||
:class="{ active: src === modelValue }"
|
||||
@click="$emit('update:modelValue', src)"
|
||||
>
|
||||
{{ LABELS[src] }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Source = 'netease' | 'qq';
|
||||
|
||||
const LABELS: Record<Source, string> = {
|
||||
netease: '网易云',
|
||||
qq: 'QQ',
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
modelValue: Source;
|
||||
sources: Source[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: Source];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 4px 10px;
|
||||
min-height: 28px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-12);
|
||||
font-weight: var(--fw-semi);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.source-tabs {
|
||||
margin-left: 8px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
min-height: 36px; // larger touch target on mobile
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Why these choices:
|
||||
- `v-if="sources.length >= 2"` — auto-hide when only one source available; parent doesn't need wrapper logic
|
||||
- Min-height 28px desktop / 36px mobile — comfortable touch on phones
|
||||
- `--color-primary-12` (12% primary tint) — matches existing active-state pattern in the codebase
|
||||
- No `--brand-netease/qq` in active state — keeps tab visually consistent regardless of which platform; brand colors are reserved for SongCard platform badges where they identify content origin
|
||||
|
||||
- [ ] **Step 1.2: Verify it imports cleanly via type check**
|
||||
|
||||
Run from project root:
|
||||
|
||||
```
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: exit code 0, no output.
|
||||
|
||||
Then verify the web project also type-checks:
|
||||
|
||||
```
|
||||
cd web && npx vue-tsc --noEmit && cd ..
|
||||
```
|
||||
|
||||
Expected: exit code 0, no output.
|
||||
|
||||
- [ ] **Step 1.3: Commit**
|
||||
|
||||
```
|
||||
git add web/src/components/SourceTabs.vue
|
||||
git commit -m "feat(web): add SourceTabs component for platform switcher
|
||||
|
||||
Presentational component for switching between netease and qq music
|
||||
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
|
||||
breakpoint enlarges touch target to 36px.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Refactor the store (state + fetchHomeData)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/stores/player.ts`
|
||||
|
||||
This task changes types, which will break Home.vue and Library.vue at compile time. **Do not run tsc/build between Task 2 and Task 4** — they are migrated in a single coherent commit. After Task 4, type-check confirms the whole change.
|
||||
|
||||
- [ ] **Step 2.1: Add the `Source` type alias and update state shape**
|
||||
|
||||
In `web/src/stores/player.ts`, locate the `state: () => ({ ... })` block (around line 47-63).
|
||||
|
||||
**Find:**
|
||||
|
||||
```ts
|
||||
// Home page cache
|
||||
recommendPlaylists: [] as PlaylistItem[],
|
||||
dailySongs: [] as Song[],
|
||||
userPlaylists: [] as PlaylistItem[],
|
||||
bilibiliPopular: [] as Song[],
|
||||
lastFetchTime: 0,
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
|
||||
```ts
|
||||
// Home page cache, split by source
|
||||
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
|
||||
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
|
||||
bilibiliPopular: [] as Song[],
|
||||
authStatus: { netease: false, qq: false },
|
||||
lastFetchTime: 0,
|
||||
```
|
||||
|
||||
Also add this exported type at the top of the file, right after the existing `Song` interface (around line 12):
|
||||
|
||||
```ts
|
||||
export type Source = 'netease' | 'qq';
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Rewrite `fetchHomeData()`**
|
||||
|
||||
In the same file, find the `fetchHomeData` action (around line 352-378).
|
||||
|
||||
**Replace the entire action body with:**
|
||||
|
||||
```ts
|
||||
async fetchHomeData() {
|
||||
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Fetch auth status for both platforms first.
|
||||
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
|
||||
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
|
||||
]);
|
||||
this.authStatus.netease =
|
||||
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
|
||||
this.authStatus.qq =
|
||||
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
|
||||
|
||||
// 2. NetEase data: recommend playlists work anonymously; daily/user
|
||||
// playlists need login but Promise.allSettled isolates failures.
|
||||
const neteasePromises = [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
|
||||
];
|
||||
|
||||
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
|
||||
// resolve to empty payloads so the same indexed handling works.
|
||||
const emptyPlaylists = { data: { playlists: [] } };
|
||||
const emptySongs = { data: { songs: [] } };
|
||||
const qqPromises = this.authStatus.qq
|
||||
? [
|
||||
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
|
||||
axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }),
|
||||
]
|
||||
: [
|
||||
Promise.resolve(emptyPlaylists),
|
||||
Promise.resolve(emptySongs),
|
||||
Promise.resolve(emptyPlaylists),
|
||||
];
|
||||
|
||||
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...neteasePromises,
|
||||
...qqPromises,
|
||||
biliPromise,
|
||||
]);
|
||||
|
||||
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
|
||||
|
||||
if (neRecPL.status === 'fulfilled') {
|
||||
this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? [];
|
||||
}
|
||||
if (neDaily.status === 'fulfilled') {
|
||||
this.dailySongs.netease = neDaily.value.data.songs ?? [];
|
||||
}
|
||||
if (neUserPL.status === 'fulfilled') {
|
||||
this.userPlaylists.netease = neUserPL.value.data.playlists ?? [];
|
||||
}
|
||||
if (qqRecPL.status === 'fulfilled') {
|
||||
this.recommendPlaylists.qq = qqRecPL.value.data.playlists ?? [];
|
||||
}
|
||||
if (qqDaily.status === 'fulfilled') {
|
||||
this.dailySongs.qq = qqDaily.value.data.songs ?? [];
|
||||
}
|
||||
if (qqUserPL.status === 'fulfilled') {
|
||||
this.userPlaylists.qq = qqUserPL.value.data.playlists ?? [];
|
||||
}
|
||||
if (bili.status === 'fulfilled') {
|
||||
this.bilibiliPopular = bili.value.data.songs ?? [];
|
||||
}
|
||||
|
||||
this.lastFetchTime = Date.now();
|
||||
},
|
||||
```
|
||||
|
||||
**Do NOT type-check yet** — Home/Library still reference the old shape. They'll be migrated in Tasks 3 and 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migrate Home.vue to multi-source tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Home.vue`
|
||||
|
||||
- [ ] **Step 3.1: Add a localStorage helper module**
|
||||
|
||||
Create `web/src/stores/sourceTabs.ts`:
|
||||
|
||||
```ts
|
||||
import type { Source } from './player.js';
|
||||
|
||||
const STORAGE_KEY = 'source-tabs';
|
||||
|
||||
export type TabKey =
|
||||
| 'home.recommend'
|
||||
| 'home.daily'
|
||||
| 'home.user'
|
||||
| 'library.user';
|
||||
|
||||
function readAll(): Partial<Record<TabKey, Source>> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
|
||||
const all = readAll();
|
||||
const v = all[key];
|
||||
return v === 'netease' || v === 'qq' ? v : fallback;
|
||||
}
|
||||
|
||||
export function saveTabSource(key: TabKey, value: Source): void {
|
||||
try {
|
||||
const all = readAll();
|
||||
all[key] = value;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
// localStorage may be unavailable (private browsing); silently no-op
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a separate file rather than inline so Library can reuse it without duplication.
|
||||
|
||||
- [ ] **Step 3.2: Update Home.vue template**
|
||||
|
||||
Replace the three `<section>` blocks (推荐歌单 / 每日推荐 / 我的歌单) and the `<script setup>` block.
|
||||
|
||||
**Find** the entire `<template>` 推荐歌单 section (currently around lines 53-67):
|
||||
|
||||
```vue
|
||||
<!-- 推荐歌单 -->
|
||||
<section class="section" v-if="store.recommendPlaylists.length > 0">
|
||||
<h2 class="section-title">推荐歌单</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="playlist in store.recommendPlaylists"
|
||||
:key="playlist.id"
|
||||
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ playlist.name }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
|
||||
```vue
|
||||
<!-- 推荐歌单 -->
|
||||
<section class="section" v-if="recommendAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
推荐歌单
|
||||
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
|
||||
:key="playlist.id"
|
||||
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ playlist.name }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Find** the 每日推荐 section (currently around lines 36-51):
|
||||
|
||||
```vue
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="store.dailySongs.length > 0">
|
||||
<h2 class="section-title">每日推荐</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in store.dailySongs.slice(0, 12)"
|
||||
: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>
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
|
||||
```vue
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
每日推荐
|
||||
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
|
||||
: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>
|
||||
```
|
||||
|
||||
**Find** the 我的歌单 section (currently around lines 69-95):
|
||||
|
||||
```vue
|
||||
<!-- 我的歌单 -->
|
||||
<section class="section" v-if="store.userPlaylists.length > 0">
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span class="section-count">{{ store.userPlaylists.length }}</span>
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="pl in visibleUserPlaylists"
|
||||
:key="pl.id"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ pl.name }}</div>
|
||||
<div class="playlist-count">{{ pl.songCount }} 首</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button
|
||||
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT"
|
||||
class="expand-btn"
|
||||
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
|
||||
>
|
||||
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
|
||||
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }}
|
||||
</button>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
|
||||
```vue
|
||||
<!-- 我的歌单 -->
|
||||
<section class="section" v-if="userAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="pl in visibleUserPlaylists"
|
||||
:key="pl.id"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ pl.name }}</div>
|
||||
<div class="playlist-count">{{ pl.songCount }} 首</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button
|
||||
v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
|
||||
class="expand-btn"
|
||||
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
|
||||
>
|
||||
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
|
||||
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
|
||||
</button>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3: Update Home.vue `<script setup>`**
|
||||
|
||||
**Find** the `<script setup lang="ts">` block (currently around lines 119-153):
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore, type Song } from '../stores/player.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const USER_PLAYLIST_LIMIT = 20;
|
||||
const userPlaylistsExpanded = ref(false);
|
||||
const visibleUserPlaylists = computed(() =>
|
||||
userPlaylistsExpanded.value
|
||||
? store.userPlaylists
|
||||
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT)
|
||||
);
|
||||
|
||||
async function playFm() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/personal/fm');
|
||||
const songs: Song[] = res.data.songs;
|
||||
if (songs.length > 0) {
|
||||
await store.play(songs[0].name, songs[0].platform);
|
||||
for (let i = 1; i < songs.length; i++) {
|
||||
await store.addToQueue(songs[i].name, songs[i].platform);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchHomeData();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Replace with:**
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
||||
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SourceTabs from '../components/SourceTabs.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const USER_PLAYLIST_LIMIT = 20;
|
||||
const userPlaylistsExpanded = ref(false);
|
||||
|
||||
// Available sources per section. Recommend playlists are public for both
|
||||
// platforms — netease always; qq only when logged in. Daily and user
|
||||
// playlists need login on both sides.
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = [];
|
||||
if (store.authStatus.netease) s.push('netease');
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
return s;
|
||||
});
|
||||
const userAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = [];
|
||||
if (store.authStatus.netease) s.push('netease');
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
return s;
|
||||
});
|
||||
|
||||
// Persisted active source per section.
|
||||
const recommendSource = ref<Source>(loadTabSource('home.recommend'));
|
||||
const dailySource = ref<Source>(loadTabSource('home.daily'));
|
||||
const userSource = ref<Source>(loadTabSource('home.user'));
|
||||
|
||||
watch(recommendSource, (v) => saveTabSource('home.recommend', v));
|
||||
watch(dailySource, (v) => saveTabSource('home.daily', v));
|
||||
watch(userSource, (v) => saveTabSource('home.user', v));
|
||||
|
||||
// Fallback when persisted source is no longer available (e.g. user logged
|
||||
// out of QQ since last visit). We render against `*Safe` but never write
|
||||
// back, so the user's preference is preserved for when they log in again.
|
||||
const recommendSourceSafe = computed<Source>(() =>
|
||||
recommendAvailable.value.includes(recommendSource.value)
|
||||
? recommendSource.value
|
||||
: recommendAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const dailySourceSafe = computed<Source>(() =>
|
||||
dailyAvailable.value.includes(dailySource.value)
|
||||
? dailySource.value
|
||||
: dailyAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const userSourceSafe = computed<Source>(() =>
|
||||
userAvailable.value.includes(userSource.value)
|
||||
? userSource.value
|
||||
: userAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
|
||||
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
|
||||
const visibleUserPlaylists = computed(() =>
|
||||
userPlaylistsExpanded.value
|
||||
? currentUserPlaylists.value
|
||||
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
||||
);
|
||||
|
||||
async function playFm() {
|
||||
try {
|
||||
const res = await axios.get('/api/music/personal/fm');
|
||||
const songs: Song[] = res.data.songs;
|
||||
if (songs.length > 0) {
|
||||
await store.play(songs[0].name, songs[0].platform);
|
||||
for (let i = 1; i < songs.length; i++) {
|
||||
await store.addToQueue(songs[i].name, songs[i].platform);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchHomeData();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Note: The 我的歌单 template uses `userSource` (not `userSourceSafe`) on the `<SourceTabs>` v-model so the user's click maps directly to the persisted ref. The grid below the tabs uses `currentUserPlaylists` which derives from `userSourceSafe`, so even if `userSource` points at an unavailable platform momentarily, the grid still renders something sensible. Same pattern for 推荐歌单 / 每日推荐.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate Library.vue and remove dead code
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Library.vue`
|
||||
|
||||
- [ ] **Step 4.1: Replace the template**
|
||||
|
||||
**Find** the `<template>` block (currently lines 1-64) and **replace the entire template with:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="library-page">
|
||||
<h1 class="page-title">音乐库</h1>
|
||||
|
||||
<!-- 我的歌单 -->
|
||||
<section class="section" v-if="userAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
我的歌单
|
||||
<span class="section-count">{{ currentUserPlaylists.length }}</span>
|
||||
<SourceTabs v-model="userSource" :sources="userAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="pl in currentUserPlaylists"
|
||||
:key="pl.id"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ pl.name }}</div>
|
||||
<div class="playlist-count">{{ pl.songCount }} 首</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最近播放 -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">最近播放</h2>
|
||||
<div v-if="historyLoading" class="loading">加载中...</div>
|
||||
<div v-else-if="history.length === 0" class="empty">暂无播放记录</div>
|
||||
<div v-else class="song-list">
|
||||
<SongCard
|
||||
v-for="(song, i) in history.slice(0, 10)"
|
||||
:key="`hist-${song.id}-${i}`"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.play(song.name, song.platform)"
|
||||
@add="store.addToQueue(song.name, song.platform)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!historyLoading && userAvailable.length === 0 && history.length === 0" class="empty-state">
|
||||
<Icon icon="mdi:music-box-outline" class="empty-icon" />
|
||||
<div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Changes from the previous version:
|
||||
- "我的歌单" section: same data binding pattern as Home (`userAvailable`, `currentUserPlaylists`, `<SourceTabs>`)
|
||||
- "我的收藏" section: removed entirely (the `/api/music/user/liked` endpoint never existed)
|
||||
- Empty state condition: replaced `liked.length === 0` with `userAvailable.length === 0`
|
||||
|
||||
- [ ] **Step 4.2: Replace the script block**
|
||||
|
||||
**Find** the `<script setup lang="ts">` block (currently lines 66-105) and **replace with:**
|
||||
|
||||
```ts
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
||||
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
import SourceTabs from '../components/SourceTabs.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
|
||||
const history = ref<Song[]>([]);
|
||||
const historyLoading = ref(true);
|
||||
|
||||
const userAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = [];
|
||||
if (store.authStatus.netease) s.push('netease');
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
return s;
|
||||
});
|
||||
|
||||
const userSource = ref<Source>(loadTabSource('library.user'));
|
||||
watch(userSource, (v) => saveTabSource('library.user', v));
|
||||
|
||||
const userSourceSafe = computed<Source>(() =>
|
||||
userAvailable.value.includes(userSource.value)
|
||||
? userSource.value
|
||||
: userAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
|
||||
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.activeBotId) {
|
||||
await store.fetchBots();
|
||||
}
|
||||
|
||||
store.fetchHomeData();
|
||||
|
||||
if (store.activeBotId) {
|
||||
try {
|
||||
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
|
||||
history.value = res.data.history ?? [];
|
||||
} catch {
|
||||
// API may not be ready
|
||||
}
|
||||
}
|
||||
|
||||
historyLoading.value = false;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Changes:
|
||||
- Removed `liked` ref and the `/api/music/user/liked` axios call
|
||||
- Added auth-driven `userAvailable`, persisted `userSource`, and `currentUserPlaylists` computed
|
||||
- Imports `Source` type and `SourceTabs` component
|
||||
|
||||
- [ ] **Step 4.3: Style — `.section-title` already supports inline children**
|
||||
|
||||
The existing `.section-title` style (Library.vue and Home.vue both) already uses `display: flex; align-items: center; gap: 8px;`. SourceTabs uses `display: inline-flex` with its own `margin-left`, so it sits inline with the title and count. **No style changes are required in either Home.vue or Library.vue.**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Verify the build and types
|
||||
|
||||
- [ ] **Step 5.1: Run TypeScript backend type check**
|
||||
|
||||
```
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: exit code 0, no output. (No backend files were touched.)
|
||||
|
||||
- [ ] **Step 5.2: Run web type check + production build**
|
||||
|
||||
```
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
Expected: build completes with `✓ built in N.NNs` and no TypeScript errors. The script runs `vue-tsc --noEmit && vite build`, so failures here mean a type or template error in our changes.
|
||||
|
||||
- [ ] **Step 5.3: Run the existing test suite to confirm no regression**
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Expected: same baseline as before this feature (`Test Files 2 failed | 26 passed (28)`, `Tests 2 failed | 161 passed (163)`). The 2 pre-existing failures are in `dist/` and `.claude/worktrees/` and are unrelated to our changes — they should remain at exactly 2.
|
||||
|
||||
If any **source-tree** test fails (anything not in `dist/` or `.claude/worktrees/`), stop and investigate.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual smoke test on the dev server
|
||||
|
||||
This task verifies behavior the type system can't catch.
|
||||
|
||||
- [ ] **Step 6.1: Start the dev server**
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Wait for `Web server started` and `WebUI: http://localhost:3000` log lines.
|
||||
|
||||
- [ ] **Step 6.2: Test scenario A — only NetEase logged in**
|
||||
|
||||
Open http://localhost:3000 in a browser. Confirm:
|
||||
|
||||
- 推荐歌单 section displays NetEase playlists, **no tab bar visible** (single source, SourceTabs auto-hidden)
|
||||
- 每日推荐 section: visible only if NetEase login provides daily songs; **no tab bar**
|
||||
- 我的歌单 section: visible if NetEase has user playlists; **no tab bar**
|
||||
- Navigate to `/library`: 我的歌单 section: same — no tab bar, NetEase playlists shown
|
||||
|
||||
Open DevTools → Application → Local Storage → `localhost:3000` → `source-tabs` should be absent or `{}` (no clicks happened).
|
||||
|
||||
- [ ] **Step 6.3: Test scenario B — both NetEase and QQ logged in**
|
||||
|
||||
If QQ is not logged in, log in via Settings → QQ Music → 扫码登录.
|
||||
|
||||
Hard reload the browser (Cmd/Ctrl+Shift+R) to bypass the 5-min `lastFetchTime` cache.
|
||||
|
||||
Confirm:
|
||||
|
||||
- 推荐歌单: tab bar shows `[网易云] [QQ]`, NetEase active by default
|
||||
- Click `QQ` — playlist grid switches to QQ data, no flicker (data already in store)
|
||||
- Click `网易云` — back to NetEase
|
||||
- Same for 每日推荐 and 我的歌单
|
||||
- Navigate to `/library`, confirm 我的歌单 has its own tab bar with independent state
|
||||
- Reload the page — Home tabs and Library tab persist their last-selected source independently
|
||||
|
||||
Check `localStorage['source-tabs']`: should contain JSON with up to 4 keys (`home.recommend`, `home.daily`, `home.user`, `library.user`).
|
||||
|
||||
- [ ] **Step 6.4: Test scenario C — fallback when persisted source becomes unavailable**
|
||||
|
||||
While logged into both:
|
||||
1. On Home, switch 推荐歌单 to `QQ`. Confirm `localStorage['source-tabs']['home.recommend'] === 'qq'`.
|
||||
2. Go to Settings → log out of QQ.
|
||||
3. Hard-reload Home.
|
||||
|
||||
Confirm:
|
||||
- 推荐歌单 tab bar disappears (only NetEase available)
|
||||
- Grid shows NetEase playlists (graceful fallback via `recommendSourceSafe`)
|
||||
- `localStorage['source-tabs']['home.recommend']` is **still `'qq'`** (preference preserved)
|
||||
4. Log back into QQ → reload → 推荐歌单 grid is QQ again (preference restored)
|
||||
|
||||
- [ ] **Step 6.5: Test mobile layout**
|
||||
|
||||
Open DevTools → Toggle device toolbar → set width to 375px (iPhone SE).
|
||||
|
||||
Confirm on Home and Library:
|
||||
- Section title + tab bar fit on the same line without overflow
|
||||
- Tab buttons are at least 36px tall (use Inspect → check computed `min-height`)
|
||||
- Tabs are tappable (clicking still switches sources)
|
||||
|
||||
- [ ] **Step 6.6: Stop the dev server**
|
||||
|
||||
Kill the `npm run dev` process.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Final commit
|
||||
|
||||
- [ ] **Step 7.1: Stage and commit Task 2 + 3 + 4 changes**
|
||||
|
||||
```
|
||||
git add web/src/stores/player.ts web/src/stores/sourceTabs.ts web/src/views/Home.vue web/src/views/Library.vue
|
||||
git status
|
||||
```
|
||||
|
||||
Expected `git status` output: 4 modified/new files staged, working tree otherwise clean (apart from pre-existing `.claude/worktrees/` and `test-ts6-version.cjs` untracked).
|
||||
|
||||
```
|
||||
git commit -m "feat(web): per-platform source tabs on Home and Library
|
||||
|
||||
Recommend playlists, daily songs, and user playlists on Home now show
|
||||
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
|
||||
gets the same tab. Selection persists per-section in localStorage and
|
||||
falls back gracefully when the persisted source becomes unavailable
|
||||
(e.g., user logged out). Removes dead 我的收藏 block from Library that
|
||||
referenced a non-existent /api/music/user/liked endpoint.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 7.2: Verify commit landed**
|
||||
|
||||
```
|
||||
git log --oneline -3
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
<sha> feat(web): per-platform source tabs on Home and Library
|
||||
<sha> feat(web): add SourceTabs component for platform switcher
|
||||
<sha> docs: spec for multi-source tabs on Home and Library
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done
|
||||
|
||||
The branch should now have 3 new commits on top of the merge commit, all green builds and tests, and the feature working in dev mode.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,520 @@
|
||||
# Album Search & Playback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Surface albums in search results and allow playing the whole album from the web UI. Currently `SearchResult.albums` is always `[]` and Search.vue only renders songs.
|
||||
|
||||
**Architecture:** Extend `search()` in netease + qq providers to populate `albums`. Aggregate them in `/search/all`. Add a new "专辑" (and "歌单") section to Search.vue. Reuse `Playlist.vue` as the album detail page by branching on `route.meta.kind` between `/playlist/:id` and `/album/:id` endpoints. The existing `getAlbumSongs(id)` and `/api/music/album/:id` endpoint already work.
|
||||
|
||||
**Tech Stack:** Node 20 + TS, Express 5, Vue 3 + Vue Router 4, axios. No new deps.
|
||||
|
||||
---
|
||||
|
||||
## Spec Reference
|
||||
|
||||
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "专辑搜索".
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/music/provider.ts` | Read-only | Verify `Album` and `SearchResult.albums` shape (no change expected) |
|
||||
| `src/music/netease.ts` | Modify | `search()` adds a third parallel call (`type=10`) and maps albums |
|
||||
| `src/music/qq.ts` | Modify | `search()` adds `req_album` section and maps albums |
|
||||
| `src/music/netease.test.ts` | Modify | New tests for albums in search response |
|
||||
| `src/music/qq.test.ts` | Modify (or create if absent) | Tests for albums in qq search |
|
||||
| `src/web/api/music.ts` | Modify | `/search/all` returns `{songs, albums, playlists}` |
|
||||
| `web/src/views/Search.vue` | Modify | Render albums + playlists sections |
|
||||
| `web/src/views/Playlist.vue` | Modify | Branch endpoint by `route.meta.kind === 'album'` |
|
||||
| `web/src/router/index.ts` | Modify | Add `/album/:id` route reusing Playlist component, set `meta.kind = 'album'` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- TDD throughout. Each task: failing test → implement → verify → commit.
|
||||
- Mock HTTP via existing fixtures pattern (look at `src/music/netease.test.ts` for setup).
|
||||
- Keep all platform-specific quirks inside the provider class — no leaking into Search.vue logic.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: netease.ts — fetch albums in search
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music/netease.ts`
|
||||
- Modify: `src/music/netease.test.ts`
|
||||
|
||||
- [ ] **Step 1: Read the existing test setup so we mock the same way**
|
||||
|
||||
```bash
|
||||
grep -n 'cloudsearch\|MockAdapter\|axios.create\|mock\|nock\|fixture' src/music/netease.test.ts | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Append to `src/music/netease.test.ts` inside the existing `describe`:
|
||||
|
||||
```ts
|
||||
it("populates SearchResult.albums from cloudsearch type=10", async () => {
|
||||
// Adjust the fixture/mock helper to your existing test pattern.
|
||||
// The test should: arrange a mock that returns a non-empty albums array
|
||||
// for type=10, run search(), assert result.albums has the expected shape.
|
||||
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 10 }) }).reply(200, {
|
||||
result: {
|
||||
albums: [
|
||||
{ id: 42, name: "Album A", picUrl: "https://x/p.jpg", artists: [{ name: "Artist X" }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1 }) }).reply(200, { result: { songs: [] } });
|
||||
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1000 }) }).reply(200, { result: { playlists: [] } });
|
||||
|
||||
const provider = makeProvider();
|
||||
const r = await provider.search("foo", 5);
|
||||
expect(r.albums).toEqual([
|
||||
{ id: "42", name: "Album A", artist: "Artist X", coverUrl: "https://x/p.jpg", platform: "netease" },
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
If the existing tests use a different mock library (e.g. `msw` or manual axios stubbing), translate the fixture above to match. Do not introduce new test deps.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/music/netease.test.ts`
|
||||
Expected: FAIL — `result.albums` is `[]`
|
||||
|
||||
- [ ] **Step 4: Implement the change**
|
||||
|
||||
In `src/music/netease.ts` `search()` (~line 93), change the `Promise.all` from 2 to 3 calls:
|
||||
|
||||
```ts
|
||||
const [songRes, playlistRes, albumRes] = await Promise.all([
|
||||
this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, ...this.cookieParams } }),
|
||||
this.api.get("/cloudsearch", { params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams } }),
|
||||
this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit: 5, ...this.cookieParams } }),
|
||||
]);
|
||||
```
|
||||
|
||||
After the existing `playlists: Playlist[] = ...` mapping, add:
|
||||
|
||||
```ts
|
||||
const albums: Album[] = (albumRes.data?.result?.albums ?? []).map((a: any) => ({
|
||||
id: String(a.id),
|
||||
name: a.name ?? "",
|
||||
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
|
||||
coverUrl: a.picUrl ?? "",
|
||||
platform: "netease",
|
||||
}));
|
||||
```
|
||||
|
||||
Update the `return { songs, playlists, albums: [] }` to `return { songs, playlists, albums }`.
|
||||
|
||||
(Make sure `Album` is imported from `./provider.js`; if not yet imported, add it to the existing import.)
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/music/netease.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/netease.ts src/music/netease.test.ts
|
||||
git commit -m "feat(netease): include albums in search results"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: qq.ts — fetch albums in search
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music/qq.ts`
|
||||
- Modify or Create: `src/music/qq.test.ts`
|
||||
|
||||
- [ ] **Step 1: Verify whether qq.test.ts exists**
|
||||
|
||||
```bash
|
||||
ls src/music/qq.test.ts
|
||||
```
|
||||
|
||||
If absent, create a minimal one mirroring `netease.test.ts` style: instantiate provider, mock the `qqDirectApi` axios instance, assert `r.albums.length > 0` after a `search()` call.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Add to `src/music/qq.test.ts`:
|
||||
|
||||
```ts
|
||||
it("populates SearchResult.albums from a parallel album search request", async () => {
|
||||
// Mock returns an album list under req_album.data.body.album.list
|
||||
mockApi.onGet("/cgi-bin/musicu.fcg").reply((cfg) => {
|
||||
const data = JSON.parse(cfg.params?.data ?? "{}");
|
||||
if (data.req_album) {
|
||||
return [200, { req_album: { data: { body: { album: { list: [
|
||||
{ albumMID: "abc", albumName: "Aero", singerName: "S", albumPic: "https://x/p.jpg" },
|
||||
] } } } } }];
|
||||
}
|
||||
if (data.req_0) {
|
||||
return [200, { req_0: { data: { body: { song: { list: [] } } } } }];
|
||||
}
|
||||
return [200, {}];
|
||||
});
|
||||
|
||||
const provider = makeProvider();
|
||||
const r = await provider.search("foo", 5);
|
||||
expect(r.albums).toEqual([
|
||||
{ id: "abc", name: "Aero", artist: "S", coverUrl: expect.stringContaining("https://"), platform: "qq" },
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
Verify the actual QQ API response shape against a real call before finalizing the field names — `albumMID` vs `mid`, `albumPic` vs `pic`, etc. If unsure, log a real response once and freeze the shape in the fixture.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/music/qq.test.ts`
|
||||
Expected: FAIL — `r.albums` is `[]`
|
||||
|
||||
- [ ] **Step 4: Implement the change**
|
||||
|
||||
In `src/music/qq.ts` `search()` (~line 63), change `reqData` to include both `req_0` (songs) and `req_album` (albums):
|
||||
|
||||
```ts
|
||||
const reqData = JSON.stringify({
|
||||
req_0: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
|
||||
},
|
||||
req_album: {
|
||||
module: "music.search.SearchCgiService",
|
||||
method: "DoSearchForQQMusicDesktop",
|
||||
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
After the existing `songs` mapping, add:
|
||||
|
||||
```ts
|
||||
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
|
||||
const albums: Album[] = albumList.map((a: any) => ({
|
||||
id: String(a.albumMID ?? a.mid ?? a.albumID ?? ""),
|
||||
name: a.albumName ?? a.title ?? "",
|
||||
artist: a.singerName ?? (a.singer ?? []).map((s: any) => s.name).join(" / "),
|
||||
coverUrl: a.albumMID
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${a.albumMID}.jpg`
|
||||
: (a.albumPic ?? ""),
|
||||
platform: "qq",
|
||||
}));
|
||||
```
|
||||
|
||||
Change `return { songs, playlists: [], albums: [] }` to `return { songs, playlists: [], albums }`.
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/music/qq.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/qq.ts src/music/qq.test.ts
|
||||
git commit -m "feat(qq): include albums in search results"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: /search/all — aggregate albums + playlists
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api/music.ts`
|
||||
|
||||
- [ ] **Step 1: Look at the current aggregation**
|
||||
|
||||
In `src/web/api/music.ts` near line 40 the `/search/all` handler builds only `songs`. Extend it.
|
||||
|
||||
- [ ] **Step 2: Write a failing integration test (if test infra allows)**
|
||||
|
||||
If there's already a test file for music.ts, add a test that mocks the providers and asserts `res.body.albums.length > 0`. If not, skip and rely on Task 1+2 unit coverage + manual verification in Task 4.
|
||||
|
||||
- [ ] **Step 3: Aggregate albums + playlists**
|
||||
|
||||
Replace the existing `songs = ...` block + `res.json({ songs })` at lines ~54–62 with:
|
||||
|
||||
```ts
|
||||
const songs = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
|
||||
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
|
||||
];
|
||||
const albums = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
|
||||
];
|
||||
const playlists = [
|
||||
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
|
||||
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
|
||||
];
|
||||
|
||||
res.json({ songs, albums, playlists });
|
||||
```
|
||||
|
||||
(Bilibili intentionally skipped for albums/playlists — no album concept; playlists likewise minor.)
|
||||
|
||||
- [ ] **Step 4: Verify by curl**
|
||||
|
||||
Build + run, then:
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:3000/api/music/search/all?q=Beyond' \
|
||||
| python3 -c 'import json,sys;d=json.load(sys.stdin);print({k: len(v) for k, v in d.items()})'
|
||||
```
|
||||
|
||||
Expected: `{'songs': N>0, 'albums': N>0, 'playlists': N>=0}`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/music.ts
|
||||
git commit -m "feat(api): /search/all returns albums and playlists"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Album route reusing Playlist.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/router/index.ts`
|
||||
- Modify: `web/src/views/Playlist.vue`
|
||||
|
||||
- [ ] **Step 1: Look at the current router config and Playlist load logic**
|
||||
|
||||
```bash
|
||||
grep -n "path:\|component:\|meta" web/src/router/index.ts
|
||||
grep -n "loadPlaylist\|/api/music/playlist\|onMounted" web/src/views/Playlist.vue
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add /album/:id route**
|
||||
|
||||
In `web/src/router/index.ts`, find the `/playlist/:id` route entry. Right after it, add:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: '/album/:id',
|
||||
component: () => import('../views/Playlist.vue'),
|
||||
meta: { kind: 'album' },
|
||||
},
|
||||
```
|
||||
|
||||
(If `/playlist/:id` is `meta:`-less, also add `meta: { kind: 'playlist' }` to it for symmetry.)
|
||||
|
||||
- [ ] **Step 3: Branch the endpoint inside Playlist.vue**
|
||||
|
||||
Find the load function (probably `onMounted(async () => { axios.get('/api/music/playlist/' + id, ...) })`). Refactor:
|
||||
|
||||
```ts
|
||||
const route = useRoute();
|
||||
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
|
||||
const endpoint = kind === 'album' ? '/api/music/album/' : '/api/music/playlist/';
|
||||
// ... use `${endpoint}${route.params.id}` ...
|
||||
```
|
||||
|
||||
For the hero metadata, the playlist endpoint returns `{songs}` only (no top-level cover/title) — verify what the Album endpoint currently returns. If both only return `{songs}`, the existing Playlist.vue must already derive the cover from somewhere (probably the first song's coverUrl, or an additional `/api/music/playlist/:id/detail` call). Keep the existing pattern; if a separate detail call is needed for albums, fetch the metadata from `/api/music/song/<firstSong.id>` to get the album name + cover, OR add a thin `/api/music/album/:id/detail` endpoint that returns `{ name, coverUrl, description }`.
|
||||
|
||||
**Decision:** if Playlist.vue currently uses ONLY `/api/music/playlist/:id` and derives metadata from songs, do the same for albums (no new endpoint). If it calls a separate detail endpoint, add a matching `/api/music/album/:id/detail` returning `{ name, coverUrl }` from the first song's `album` and `coverUrl` fields.
|
||||
|
||||
- [ ] **Step 4: Verify in browser**
|
||||
|
||||
Run `cd web && npm run dev`. Visit `/album/<some-netease-album-id>` (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/router/index.ts web/src/views/Playlist.vue
|
||||
git commit -m "feat(web): /album/:id route reusing Playlist view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Search.vue — render albums + playlists sections
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Search.vue`
|
||||
|
||||
- [ ] **Step 1: Read current Search.vue**
|
||||
|
||||
```bash
|
||||
sed -n '1,120p' web/src/views/Search.vue
|
||||
```
|
||||
|
||||
Identify: the `results.value = res.data.songs` line and the `<div v-else-if="results.length > 0">` block.
|
||||
|
||||
- [ ] **Step 2: Refactor to three result lists**
|
||||
|
||||
Replace the script:
|
||||
|
||||
```ts
|
||||
import type { Song } from '../stores/player.js';
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; platform: string; }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const albums = ref<Album[]>([]);
|
||||
const playlists = ref<Playlist[]>([]);
|
||||
const loading = ref(false);
|
||||
const searched = ref(false);
|
||||
|
||||
async function doSearch() {
|
||||
if (!query.value.trim()) return;
|
||||
loading.value = true;
|
||||
searched.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||
songs.value = res.data.songs ?? [];
|
||||
albums.value = res.data.albums ?? [];
|
||||
playlists.value = res.data.playlists ?? [];
|
||||
} catch {
|
||||
songs.value = []; albums.value = []; playlists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render the sections**
|
||||
|
||||
Replace the existing `<div v-else-if="results.length > 0" class="results">` block:
|
||||
|
||||
```vue
|
||||
<template v-else-if="songs.length || albums.length || playlists.length">
|
||||
<section v-if="albums.length" class="result-section">
|
||||
<h2 class="section-title">专辑</h2>
|
||||
<div class="card-grid">
|
||||
<router-link
|
||||
v-for="al in albums"
|
||||
:key="`${al.platform}-${al.id}`"
|
||||
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||
class="card hover-scale"
|
||||
>
|
||||
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="card-name">{{ al.name }}</div>
|
||||
<div class="card-sub">{{ al.artist }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="playlists.length" class="result-section">
|
||||
<h2 class="section-title">歌单</h2>
|
||||
<div class="card-grid">
|
||||
<router-link
|
||||
v-for="pl in playlists"
|
||||
:key="`${pl.platform}-${pl.id}`"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="card-name">{{ pl.name }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="songs.length" class="result-section">
|
||||
<h2 class="section-title">单曲</h2>
|
||||
<SongCard
|
||||
v-for="(song, i) in songs"
|
||||
:key="`${song.platform}-${song.id}`"
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.playSong(song)"
|
||||
@playNext="store.playNextSong(song)"
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else-if="searched" class="empty">未找到相关结果</div>
|
||||
```
|
||||
|
||||
(Import `CoverArt`: `import CoverArt from '../components/CoverArt.vue';`.)
|
||||
|
||||
- [ ] **Step 4: Add minimal styles**
|
||||
|
||||
Append to the `<style lang="scss" scoped>` block:
|
||||
|
||||
```scss
|
||||
.result-section {
|
||||
margin-bottom: 32px;
|
||||
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
|
||||
.card-sub { font-size: 12px; opacity: 0.6; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build + visually verify**
|
||||
|
||||
```bash
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
Then `npm run dev` → search "周杰伦" → see three sections; click an album card → arrives at `/album/:id` with songs + play-all.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/views/Search.vue
|
||||
git commit -m "feat(web): show album + playlist sections in search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Open PR
|
||||
|
||||
- [ ] **Step 1: Push branch**
|
||||
|
||||
```bash
|
||||
git checkout -b feat/album-search
|
||||
git push -u origin feat/album-search
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the PR**
|
||||
|
||||
```bash
|
||||
gh pr create --title "feat(search): album section + album playback" --body "Closes part of #51 (album half).
|
||||
|
||||
## Summary
|
||||
- netease.search() / qq.search() now populate SearchResult.albums
|
||||
- /api/music/search/all returns albums + playlists alongside songs
|
||||
- Search.vue renders three sections: 专辑 / 歌单 / 单曲
|
||||
- /album/:id route reuses Playlist.vue with meta.kind='album'
|
||||
- bilibili / youtube intentionally still return albums:[] (no album API)
|
||||
|
||||
## Test plan
|
||||
- [x] vitest covers netease + qq search returning non-empty albums
|
||||
- [x] curl /search/all?q=周杰伦 returns {songs, albums, playlists}
|
||||
- [x] Manual: search → click album card → /album/:id → play all
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] Spec coverage: backend album search → Tasks 1+2; aggregator → Task 3; album detail route → Task 4; UI sections → Task 5
|
||||
- [x] No "TBD"/placeholder text — every step shows the actual diff or command
|
||||
- [x] Type names consistent: `Album` (capital A), `albums` (lowercase plural), `SearchResult.albums`
|
||||
- [x] Bilibili/YouTube explicitly out of scope per spec — confirmed in Task 3 by skipping them in albums aggregation
|
||||
- [x] Routes use `meta.kind` — same key referenced in Playlist.vue (Task 4) and `/album/:id` registration (Task 4 Step 2)
|
||||
@@ -0,0 +1,903 @@
|
||||
# Custom Bot Avatar Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users upload a fixed avatar per bot. When `avatarEnabled=true`, the avatar follows the song cover during playback and reverts to the custom avatar (instead of clearing) on stop. When `avatarEnabled=false` and a custom avatar exists, the bot always shows the custom avatar.
|
||||
|
||||
**Architecture:** New SQLite column stores a relative file path; bytes live on disk under `data/avatars/<botId>.<ext>` (mirrors `data/cookies/`). `BotProfileManager` gains a `customAvatar` Buffer; the existing `clearAvatar()` becomes "restore custom or clear"; `onConnect()` immediately applies the custom avatar when sync is off. Three new REST endpoints (GET/PUT/DELETE) under `/api/bot/:id/avatar` accept base64 JSON (avoids adding multer; bump `express.json()` limit).
|
||||
|
||||
**Tech Stack:** Node 20 + TS + Express 5, better-sqlite3, Vue 3 + axios. No new runtime deps.
|
||||
|
||||
---
|
||||
|
||||
## Spec Reference
|
||||
|
||||
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "自定义头像".
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/data/database.ts` | Modify | Add `custom_avatar_path` column + migration + accessor methods |
|
||||
| `src/data/avatars.ts` | **Create** | Read/write/delete avatar files under `data/avatars/` |
|
||||
| `src/bot/profile.ts` | Modify | `customAvatar` field, `setCustomAvatar`, `applyIdleAvatar`, modify `clearAvatar`, modify `onConnect` |
|
||||
| `src/bot/instance.ts` | Modify | Load custom avatar on start, pass to ProfileManager |
|
||||
| `src/web/api/bot.ts` | Modify | Add GET/PUT/DELETE `/avatar` endpoints |
|
||||
| `src/web/server.ts` | Modify | Bump `express.json()` limit to `400kb` |
|
||||
| `src/index.ts` | Modify | Pass `AVATAR_DIR` to bot manager / API router |
|
||||
| `src/data/database.test.ts` | Modify | Test custom avatar path persistence + migration idempotency |
|
||||
| `src/data/avatars.test.ts` | **Create** | Unit tests for avatar store |
|
||||
| `src/bot/profile.test.ts` | **Create** | Tests for new precedence logic with a mock TS3Client |
|
||||
| `web/src/components/AvatarUpload.vue` | **Create** | Reusable avatar picker + preview + delete |
|
||||
| `web/src/views/Settings.vue` | Modify | Add custom avatar row in profile features list; insert into create-bot and edit-bot forms |
|
||||
|
||||
## Conventions
|
||||
|
||||
- TDD: failing test → implement → verify → commit, every step.
|
||||
- Commits use conventional format: `feat(profile):`, `feat(api):`, `feat(web):`, `test(...)`. Each task ends with one commit.
|
||||
- Tests live in vitest (`npm test`).
|
||||
- All paths absolute or relative to repo root.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: DB migration + getter/setter for custom avatar path
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/database.ts`
|
||||
- Modify: `src/data/database.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `src/data/database.test.ts` after the existing tests (find the closing `});` of the last test case in the `describe` block, insert before it):
|
||||
|
||||
```ts
|
||||
it("persists and clears customAvatarPath on a bot instance", () => {
|
||||
const inst = {
|
||||
id: "bot-1",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
botDb.saveBotInstance(inst);
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
|
||||
botDb.setCustomAvatarPath("bot-1", null);
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/data/database.test.ts`
|
||||
Expected: FAIL — `botDb.getCustomAvatarPath is not a function`
|
||||
|
||||
- [ ] **Step 3: Add the column to migration + interface + statements**
|
||||
|
||||
In `src/data/database.ts`:
|
||||
|
||||
1. Find `BotDatabase` interface (~line 54), add two methods before `close()`:
|
||||
|
||||
```ts
|
||||
getCustomAvatarPath(botId: string): string | null;
|
||||
setCustomAvatarPath(botId: string, path: string | null): void;
|
||||
```
|
||||
|
||||
2. Find `migrateSchema()` (~line 66). After the `for (const col of profileCols)` loop, append:
|
||||
|
||||
```ts
|
||||
if (!names.includes("custom_avatar_path")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
|
||||
}
|
||||
```
|
||||
|
||||
3. In `createDatabase()` after the existing `prepare(...)` calls (~line 180), add:
|
||||
|
||||
```ts
|
||||
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 = ?`,
|
||||
);
|
||||
```
|
||||
|
||||
4. Inside the returned object, add (before `close()`):
|
||||
|
||||
```ts
|
||||
getCustomAvatarPath(botId) {
|
||||
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
|
||||
return row?.custom_avatar_path ?? null;
|
||||
},
|
||||
|
||||
setCustomAvatarPath(botId, path) {
|
||||
updateCustomAvatar.run(path, botId);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/data/database.test.ts`
|
||||
Expected: PASS — all tests including the new one
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/database.ts src/data/database.test.ts
|
||||
git commit -m "feat(db): custom_avatar_path column + accessors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Avatar storage helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data/avatars.ts`
|
||||
- Create: `src/data/avatars.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/data/avatars.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAvatarStore } from "./avatars.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
|
||||
});
|
||||
|
||||
describe("createAvatarStore", () => {
|
||||
it("write returns a relative path under the store dir", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
const buf = Buffer.from("fake-png");
|
||||
const rel = store.write("bot-1", "image/png", buf);
|
||||
expect(rel).toBe("bot-1.png");
|
||||
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
|
||||
});
|
||||
|
||||
it("write picks correct extension for jpeg / webp", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
|
||||
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
|
||||
});
|
||||
|
||||
it("write rejects unsupported MIME types", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(
|
||||
/unsupported/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("read returns the bytes for an existing file", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("hello"));
|
||||
const buf = store.read("bot-1.png");
|
||||
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
|
||||
});
|
||||
|
||||
it("read returns null when path is missing", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(store.read("missing.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("remove deletes the file (idempotent)", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("x"));
|
||||
store.remove("bot-1.png");
|
||||
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||
expect(() => store.remove("bot-1.png")).not.toThrow();
|
||||
});
|
||||
|
||||
it("write replaces any existing file for the same botId regardless of old extension", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("old"));
|
||||
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
|
||||
expect(rel).toBe("bot-1.jpg");
|
||||
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/data/avatars.test.ts`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement the store**
|
||||
|
||||
Create `src/data/avatars.ts`:
|
||||
|
||||
```ts
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
export interface AvatarStore {
|
||||
/** Returns the relative path written (e.g. "bot-1.png"). */
|
||||
write(botId: string, mime: string, buffer: Buffer): string;
|
||||
read(relPath: string): Buffer | null;
|
||||
remove(relPath: string): void;
|
||||
getDir(): string;
|
||||
}
|
||||
|
||||
export function createAvatarStore(dir: string): AvatarStore {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return {
|
||||
write(botId, mime, buffer) {
|
||||
const ext = MIME_TO_EXT[mime];
|
||||
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
|
||||
// Remove any existing avatar for this bot regardless of extension.
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
|
||||
}
|
||||
const rel = `${botId}.${ext}`;
|
||||
writeFileSync(join(dir, rel), buffer);
|
||||
return rel;
|
||||
},
|
||||
read(relPath) {
|
||||
const full = join(dir, relPath);
|
||||
if (!existsSync(full)) return null;
|
||||
return readFileSync(full);
|
||||
},
|
||||
remove(relPath) {
|
||||
rmSync(join(dir, relPath), { force: true });
|
||||
},
|
||||
getDir() {
|
||||
return dir;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/data/avatars.test.ts`
|
||||
Expected: PASS — all 7 tests
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/avatars.ts src/data/avatars.test.ts
|
||||
git commit -m "feat(data): avatar file store helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: BotProfileManager — custom avatar precedence
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/profile.ts`
|
||||
- Create: `src/bot/profile.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/bot/profile.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import type { TS3Client } from "../ts-protocol/client.js";
|
||||
|
||||
function makeMockTs(): TS3Client & {
|
||||
uploadCalls: Buffer[];
|
||||
clearCalls: number;
|
||||
} {
|
||||
const calls: Buffer[] = [];
|
||||
let clears = 0;
|
||||
const ts: any = {
|
||||
uploadCalls: calls,
|
||||
get clearCalls() { return clears; },
|
||||
getHost: () => "127.0.0.1",
|
||||
getHttpQuery: () => null,
|
||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||
uploadFileData: vi.fn().mockImplementation(async (_h, _i, stream: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(c as Buffer);
|
||||
calls.push(Buffer.concat(chunks));
|
||||
}),
|
||||
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
|
||||
if (/client_flag_avatar=$/.test(cmd)) clears++;
|
||||
}),
|
||||
};
|
||||
return ts;
|
||||
}
|
||||
|
||||
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
|
||||
const cfgOff = { ...cfgOn, avatarEnabled: false };
|
||||
|
||||
describe("BotProfileManager custom avatar precedence", () => {
|
||||
let ts: ReturnType<typeof makeMockTs>;
|
||||
beforeEach(() => { ts = makeMockTs(); });
|
||||
|
||||
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
const custom = Buffer.from([1, 2, 3, 4]);
|
||||
pm.setCustomAvatar(custom);
|
||||
await pm.onSongChange(null);
|
||||
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("on stop with no custom avatar, falls back to clear", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
await pm.onSongChange(null);
|
||||
expect(ts.clearCalls).toBe(1);
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([9, 9]));
|
||||
await pm.onConnect();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
|
||||
});
|
||||
|
||||
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
await pm.onConnect();
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([1]));
|
||||
pm.setCustomAvatar(null);
|
||||
await pm.onSongChange(null);
|
||||
expect(ts.clearCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/bot/profile.test.ts`
|
||||
Expected: FAIL — `pm.setCustomAvatar is not a function` and/or `onConnect` not exported
|
||||
|
||||
- [ ] **Step 3: Look at the existing profile.ts to understand `onConnect` shape**
|
||||
|
||||
Run: `grep -n 'onConnect\|public async\|public ' src/bot/profile.ts | head -10`
|
||||
|
||||
`onConnect` likely already exists; if not, locate where reconnect resets state. Add or extend it.
|
||||
|
||||
- [ ] **Step 4: Implement `customAvatar`, `setCustomAvatar`, `applyIdleAvatar`; modify `clearAvatar` and `onConnect`**
|
||||
|
||||
In `src/bot/profile.ts`:
|
||||
|
||||
1. Inside the class, add fields next to `defaultNickname` (around line 27):
|
||||
|
||||
```ts
|
||||
private customAvatar: Buffer | null = null;
|
||||
```
|
||||
|
||||
2. After the `constructor`, add:
|
||||
|
||||
```ts
|
||||
/** Set/clear the persistent idle avatar. Pass null to remove. */
|
||||
setCustomAvatar(buffer: Buffer | null): void {
|
||||
this.customAvatar = buffer;
|
||||
}
|
||||
```
|
||||
|
||||
3. Find `clearAvatar()` (~line 173). Change the body so that if `this.customAvatar` is set, we upload it instead of clearing the flag. Replace the existing method with:
|
||||
|
||||
```ts
|
||||
private async clearAvatar(gen: number): Promise<void> {
|
||||
if (this.customAvatar && this.customAvatar.length > 0) {
|
||||
await this.applyIdleAvatar(gen);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.withTimeout(
|
||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||
FILE_TRANSFER_TIMEOUT_MS,
|
||||
);
|
||||
} catch {
|
||||
// File may not exist or transfer timed out — that's fine
|
||||
}
|
||||
if (this.generation !== gen) return;
|
||||
try {
|
||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Add a new private method right below `clearAvatar`:
|
||||
|
||||
```ts
|
||||
private async applyIdleAvatar(gen: number): Promise<void> {
|
||||
if (!this.customAvatar || this.customAvatar.length === 0) return;
|
||||
if (this.permDenied.avatar) return;
|
||||
try {
|
||||
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
|
||||
if (this.generation !== gen) return;
|
||||
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. Find `onConnect` (the existing method that resets per-feature flags). At its end, immediately after the `permDenied` reset, add:
|
||||
|
||||
```ts
|
||||
if (!this.config.avatarEnabled && this.customAvatar) {
|
||||
const gen = ++this.generation;
|
||||
void this.applyIdleAvatar(gen);
|
||||
}
|
||||
```
|
||||
|
||||
If `onConnect` does not exist as a method, search for where reconnect resets `permDenied` and add the block there.
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/bot/profile.test.ts`
|
||||
Expected: PASS — 5/5
|
||||
|
||||
Run also: `npx vitest run src/audio src/data src/bot` — confirm no regressions.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/profile.ts src/bot/profile.test.ts
|
||||
git commit -m "feat(profile): custom avatar with idle/playback precedence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire avatar load on bot start
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/instance.ts`
|
||||
- Modify: `src/bot/manager.ts` (if it constructs the instance)
|
||||
- Modify: `src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Confirm where `BotProfileManager` is constructed and how `BotInstance` receives DB**
|
||||
|
||||
Run: `grep -n 'new BotProfileManager\|profileManager =\|database\|botDb' src/bot/instance.ts src/bot/manager.ts | head -20`
|
||||
|
||||
Identify the BotInstance constructor params and verify that the DB and the avatar dir can flow in.
|
||||
|
||||
- [ ] **Step 2: Add `AVATAR_DIR` constant + `avatarStore` to `src/index.ts`**
|
||||
|
||||
Find where `COOKIE_DIR` / `createCookieStore` are set up (~line 48 in src/index.ts) and add directly after:
|
||||
|
||||
```ts
|
||||
const AVATAR_DIR = process.env.AVATAR_DIR ?? join(DATA_DIR, "avatars");
|
||||
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
```
|
||||
|
||||
(import as needed: `import { createAvatarStore } from "./data/avatars.js";`)
|
||||
|
||||
Pass `avatarStore` through to whatever constructs `BotManager` (and from there to `BotInstance`).
|
||||
|
||||
- [ ] **Step 3: In `BotInstance`, after `profileManager` is created, load the avatar from disk if any**
|
||||
|
||||
In `src/bot/instance.ts`, after `this.profileManager = new BotProfileManager(...)`:
|
||||
|
||||
```ts
|
||||
const relPath = this.botDb.getCustomAvatarPath(this.id);
|
||||
if (relPath) {
|
||||
const buf = this.avatarStore.read(relPath);
|
||||
if (buf) this.profileManager.setCustomAvatar(buf);
|
||||
}
|
||||
```
|
||||
|
||||
(Add `private botDb: BotDatabase` and `private avatarStore: AvatarStore` constructor params; thread them down from `BotManager.createBot()` / `BotManager` constructor.)
|
||||
|
||||
- [ ] **Step 4: Add `getProfileManager()` accessor if not present**
|
||||
|
||||
If grep already shows `getProfileManager(): BotProfileManager`, skip. Otherwise add a public method that returns `this.profileManager`.
|
||||
|
||||
- [ ] **Step 5: Build and run the existing tests**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: no TS errors
|
||||
|
||||
Run: `npm test`
|
||||
Expected: all green
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts src/bot/instance.ts src/bot/manager.ts
|
||||
git commit -m "feat(bot): load custom avatar on instance startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: REST endpoints for avatar upload / fetch / delete
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/server.ts` (json size limit)
|
||||
- Modify: `src/web/api/bot.ts`
|
||||
|
||||
- [ ] **Step 1: Bump express.json size limit**
|
||||
|
||||
In `src/web/server.ts`, find `app.use(express.json())` (~line 46) and change to:
|
||||
|
||||
```ts
|
||||
app.use(express.json({ limit: "400kb" }));
|
||||
```
|
||||
|
||||
(Avatar payload is base64-encoded ≤200 KB → ~270 KB on the wire; 400 KB gives margin.)
|
||||
|
||||
- [ ] **Step 2: Write a failing API test (use supertest if not present, otherwise inline fetch)**
|
||||
|
||||
Run: `grep -E '"supertest"|"vitest"' package.json`
|
||||
|
||||
If supertest is not present, write the test using `node:http` raw client or skip API integration test and rely on manual + unit tests on Task 7. Don't add new deps unless approved.
|
||||
|
||||
If supertest IS present, add `src/web/api/bot.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import request from "supertest";
|
||||
import express from "express";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
// ... build minimal app with mocked manager + DB + avatarStore
|
||||
```
|
||||
|
||||
If not present: skip Step 2, jump to Step 3 and verify by manual curl in Step 5.
|
||||
|
||||
- [ ] **Step 3: Add the three endpoints**
|
||||
|
||||
In `src/web/api/bot.ts`, modify the factory signature to accept `avatarStore` and `botDb`:
|
||||
|
||||
```ts
|
||||
export function createBotRouter(
|
||||
botManager: BotManager,
|
||||
config: BotConfig,
|
||||
configPath: string,
|
||||
logger: Logger,
|
||||
botDb: BotDatabase,
|
||||
avatarStore: AvatarStore,
|
||||
): Router {
|
||||
```
|
||||
|
||||
Inside the router, after the existing `/:id/config` GET, add:
|
||||
|
||||
```ts
|
||||
router.get("/:id/avatar", (req, res) => {
|
||||
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||
if (!path) { res.status(404).end(); return; }
|
||||
const buf = avatarStore.read(path);
|
||||
if (!buf) { res.status(404).end(); return; }
|
||||
const ext = path.split(".").pop()!;
|
||||
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
|
||||
res.set("Content-Type", mime);
|
||||
res.set("Cache-Control", "no-cache");
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
router.put("/:id/avatar", (req, res) => {
|
||||
const bot = botManager.getBot(req.params.id);
|
||||
if (!bot && !botDb.getBotInstances().some((b) => b.id === req.params.id)) {
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
return;
|
||||
}
|
||||
const { dataUrl } = req.body as { dataUrl?: string };
|
||||
if (typeof dataUrl !== "string") {
|
||||
res.status(400).json({ error: "dataUrl required" });
|
||||
return;
|
||||
}
|
||||
const m = /^data:(image\/(png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
|
||||
if (!m) {
|
||||
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
|
||||
return;
|
||||
}
|
||||
const mime = m[1];
|
||||
const buf = Buffer.from(m[3], "base64");
|
||||
if (buf.length > 200 * 1024) {
|
||||
res.status(413).json({ error: "avatar exceeds 200KB limit" });
|
||||
return;
|
||||
}
|
||||
const rel = avatarStore.write(req.params.id, mime, buf);
|
||||
botDb.setCustomAvatarPath(req.params.id, rel);
|
||||
bot?.getProfileManager().setCustomAvatar(buf);
|
||||
res.json({ path: rel });
|
||||
});
|
||||
|
||||
router.delete("/:id/avatar", (req, res) => {
|
||||
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||
if (path) avatarStore.remove(path);
|
||||
botDb.setCustomAvatarPath(req.params.id, null);
|
||||
const bot = botManager.getBot(req.params.id);
|
||||
bot?.getProfileManager().setCustomAvatar(null);
|
||||
res.status(204).end();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the call site that constructs the router**
|
||||
|
||||
Search: `grep -n 'createBotRouter' src/`
|
||||
|
||||
In the call site (likely `src/web/server.ts` or `src/index.ts`), pass the new args. Fix the call signature.
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
Run: `npm run build && npm run start`
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
# create a small valid PNG (1x1) base64
|
||||
B64=$(node -e "console.log(Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,8,153,99,248,255,255,63,0,5,254,2,254,205,250,236,184,0,0,0,0,73,69,78,68,174,66,96,130]).toString('base64'))")
|
||||
|
||||
curl -X PUT http://localhost:3000/api/bot/<BOT_ID>/avatar \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"dataUrl\":\"data:image/png;base64,$B64\"}"
|
||||
|
||||
curl http://localhost:3000/api/bot/<BOT_ID>/avatar -o /tmp/x.png
|
||||
file /tmp/x.png
|
||||
|
||||
curl -X DELETE http://localhost:3000/api/bot/<BOT_ID>/avatar -i
|
||||
```
|
||||
|
||||
Expected: PUT returns `{"path":"<id>.png"}`, GET returns the bytes, DELETE returns 204.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/server.ts src/web/api/bot.ts src/index.ts
|
||||
git commit -m "feat(api): /api/bot/:id/avatar GET/PUT/DELETE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — `AvatarUpload.vue` component
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/AvatarUpload.vue`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="avatar-upload">
|
||||
<div class="preview" :class="{ empty: !previewUrl }">
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
|
||||
<Icon v-else icon="mdi:account-circle-outline" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
class="hidden"
|
||||
@change="onFile"
|
||||
/>
|
||||
<button type="button" class="btn-sm" @click="fileInput?.click()">
|
||||
{{ previewUrl ? '更换' : '上传' }}
|
||||
</button>
|
||||
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="hint error">{{ error }}</p>
|
||||
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const props = defineProps<{ modelValue: string | null }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
|
||||
|
||||
const previewUrl = ref<string | null>(props.modelValue);
|
||||
const error = ref<string | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
|
||||
|
||||
function onFile(ev: Event) {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||
error.value = '仅支持 PNG / JPG / WebP';
|
||||
return;
|
||||
}
|
||||
if (file.size > 200 * 1024) {
|
||||
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
previewUrl.value = dataUrl;
|
||||
emit('update:modelValue', dataUrl);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
previewUrl.value = null;
|
||||
emit('update:modelValue', null);
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.preview {
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
|
||||
}
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.hidden { display: none; }
|
||||
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
|
||||
.hint.error { color: var(--color-danger, #e85060); opacity: 1; }
|
||||
.btn-danger { color: var(--color-danger, #e85060); }
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the component compiles**
|
||||
|
||||
Run: `cd web && npx vue-tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/components/AvatarUpload.vue
|
||||
git commit -m "feat(web): AvatarUpload component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Settings.vue`
|
||||
|
||||
- [ ] **Step 1: Read the relevant Settings.vue regions**
|
||||
|
||||
```bash
|
||||
grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20
|
||||
```
|
||||
|
||||
Identify:
|
||||
- Create-bot form template region (`<div class="create-bot">` block)
|
||||
- Edit-bot modal/dialog template region
|
||||
- The profile features table where `avatarEnabled` row lives
|
||||
|
||||
- [ ] **Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form**
|
||||
|
||||
In the script setup region, near other `newBot*` refs:
|
||||
|
||||
```ts
|
||||
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||
const newBotAvatar = ref<string | null>(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Insert `<AvatarUpload v-model="newBotAvatar" />` into the create-bot form template**
|
||||
|
||||
In the `<div class="create-bot">` block, right before `<button class="btn-primary" @click="createBot">创建</button>`, add:
|
||||
|
||||
```vue
|
||||
<div class="form-row">
|
||||
<label>自定义头像(可选)</label>
|
||||
<AvatarUpload v-model="newBotAvatar" />
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: After successful `createBot()`, PUT the avatar if set**
|
||||
|
||||
Find the `createBot` async function. After the POST resolves and the bot id is known (`res.data.id` or similar), append:
|
||||
|
||||
```ts
|
||||
if (newBotAvatar.value) {
|
||||
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
|
||||
}
|
||||
newBotAvatar.value = null;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add an "自定义头像" row in the per-bot profile features table**
|
||||
|
||||
Find the profile-features table render (look for the `features` array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before `</template>` of the bot row, add:
|
||||
|
||||
```vue
|
||||
<div class="feature-row">
|
||||
<div class="feature-label">自定义头像</div>
|
||||
<div class="feature-control">
|
||||
<CustomAvatarRow :bot-id="bot.id" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Where `CustomAvatarRow` is an inline-defined component or a small file `web/src/components/CustomAvatarRow.vue` that:
|
||||
- Mounts → `axios.get(/api/bot/<id>/avatar, { responseType: 'blob' })` → previews if 200, ignore 404
|
||||
- Wraps `<AvatarUpload>` and on `update:modelValue`:
|
||||
- If string → `axios.put(/avatar, { dataUrl })`
|
||||
- If null → `axios.delete(/avatar)`
|
||||
|
||||
Create `web/src/components/CustomAvatarRow.vue` with that logic; keep its body small (~50 lines).
|
||||
|
||||
- [ ] **Step 6: Build and visually verify**
|
||||
|
||||
Run: `cd web && npm run build` → no errors. Then `npm run dev` → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue
|
||||
git commit -m "feat(web): custom avatar in create-bot + Settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Open PR
|
||||
|
||||
- [ ] **Step 1: Push the branch**
|
||||
|
||||
```bash
|
||||
git checkout -b feat/custom-bot-avatar
|
||||
git push -u origin feat/custom-bot-avatar
|
||||
```
|
||||
|
||||
(If commits were already on `main`, instead create the branch from the first relevant commit and reset main: `git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar`. The exact sequence depends on the working state when starting.)
|
||||
|
||||
- [ ] **Step 2: Create the PR**
|
||||
|
||||
```bash
|
||||
gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half).
|
||||
|
||||
## Summary
|
||||
- New /api/bot/:id/avatar GET/PUT/DELETE
|
||||
- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true
|
||||
- AvatarUpload component used in create-bot form and Settings per-bot row
|
||||
- Bump express.json limit to 400kb to allow base64 payload
|
||||
|
||||
## Behavior matrix
|
||||
| avatarEnabled | custom set | playing | stopped |
|
||||
|---|---|---|---|
|
||||
| ✓ | ✓ | cover | restore custom |
|
||||
| ✓ | ✗ | cover | clear |
|
||||
| ✗ | ✓ | custom | custom |
|
||||
| ✗ | ✗ | no-op | no-op |
|
||||
|
||||
## Test plan
|
||||
- [x] vitest covers DB, avatar store, ProfileManager precedence
|
||||
- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7
|
||||
- [x] No "TBD" / "fill in" / "implement later" text in any step
|
||||
- [x] Type names consistent: `AvatarStore` / `createAvatarStore` / `getCustomAvatarPath` / `setCustomAvatarPath` / `setCustomAvatar` (singular per call site)
|
||||
- [x] All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,811 @@
|
||||
# Account Permissions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
|
||||
|
||||
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
|
||||
|
||||
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
|
||||
|
||||
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
|
||||
- `src/data/permissions.test.ts` — store + constants tests
|
||||
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
|
||||
- `src/web/middleware/requirePermission.test.ts` — middleware tests
|
||||
|
||||
**Modify:**
|
||||
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
|
||||
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
|
||||
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
|
||||
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
|
||||
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
|
||||
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
|
||||
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
|
||||
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
|
||||
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
|
||||
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
|
||||
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
|
||||
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
|
||||
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
|
||||
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Capability constants + PermissionStore + tables
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data/permissions.ts`
|
||||
- Create: `src/data/permissions.test.ts`
|
||||
- Modify: `src/data/database.ts` (initTables)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`src/data/permissions.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createPermissionStore } from "./permissions.js";
|
||||
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
|
||||
|
||||
describe("PermissionStore", () => {
|
||||
let dbFile: string;
|
||||
let db: BotDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
db = createDatabase(dbFile);
|
||||
// a user row is required for FK; insert directly
|
||||
db.db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
|
||||
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(dbFile, { force: true }); } catch {}
|
||||
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
|
||||
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
|
||||
});
|
||||
|
||||
it("exposes the five capability tokens and a basic tier", () => {
|
||||
expect(CAPABILITIES).toEqual([
|
||||
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
|
||||
]);
|
||||
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
|
||||
});
|
||||
|
||||
it("defaults to no capabilities and no bots", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
expect(store.getCapabilities("u1")).toEqual([]);
|
||||
expect(store.getBotAccess("u1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips capabilities and a specific bot list", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
|
||||
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
|
||||
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
|
||||
});
|
||||
|
||||
it("stores the all-bots flag as 'all'", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
|
||||
expect(store.getBotAccess("u1")).toBe("all");
|
||||
});
|
||||
|
||||
it("setPermissions replaces prior capabilities and bots", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
|
||||
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
|
||||
expect(store.getCapabilities("u1")).toEqual(["quality"]);
|
||||
expect(store.getBotAccess("u1")).toBe("all");
|
||||
});
|
||||
|
||||
it("ignores unknown capability tokens", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
|
||||
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
|
||||
});
|
||||
|
||||
it("pruneBot removes a bot from every user's allow-list", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
|
||||
store.pruneBot("botA");
|
||||
expect(store.getBotAccess("u1")).toEqual(["botB"]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/data/permissions.test.ts`
|
||||
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
|
||||
|
||||
- [ ] **Step 3: Create `src/data/permissions.ts`**
|
||||
|
||||
```typescript
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const CAPABILITIES = [
|
||||
"player.control",
|
||||
"player.queue",
|
||||
"bot.manage",
|
||||
"platform.auth",
|
||||
"quality",
|
||||
] as const;
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
|
||||
export const BOTS_ALL = "bots.all";
|
||||
|
||||
/** Capabilities granted to a newly-created member by default. */
|
||||
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
|
||||
|
||||
export function isCapability(x: string): x is Capability {
|
||||
return (CAPABILITIES as readonly string[]).includes(x);
|
||||
}
|
||||
|
||||
export type BotAccess = "all" | string[];
|
||||
|
||||
export interface PermissionStore {
|
||||
getCapabilities(userId: string): Capability[];
|
||||
getBotAccess(userId: string): BotAccess;
|
||||
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
|
||||
pruneBot(botId: string): void;
|
||||
}
|
||||
|
||||
export function createPermissionStore(db: Database.Database): PermissionStore {
|
||||
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
|
||||
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
|
||||
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
|
||||
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
|
||||
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
|
||||
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
|
||||
|
||||
return {
|
||||
getCapabilities(userId) {
|
||||
return (selCaps.all(userId) as { permission: string }[])
|
||||
.map((r) => r.permission)
|
||||
.filter((p): p is Capability => isCapability(p));
|
||||
},
|
||||
getBotAccess(userId) {
|
||||
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
|
||||
if (all) return "all";
|
||||
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
|
||||
},
|
||||
setPermissions(userId, input) {
|
||||
const caps = input.capabilities.filter(isCapability);
|
||||
const tx = db.transaction(() => {
|
||||
delCaps.run(userId);
|
||||
delBots.run(userId);
|
||||
for (const c of caps) insCap.run(userId, c);
|
||||
if (input.bots === "all") {
|
||||
insCap.run(userId, BOTS_ALL);
|
||||
} else {
|
||||
for (const b of input.bots) insBot.run(userId, b);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
},
|
||||
pruneBot(botId) {
|
||||
pruneBotStmt.run(botId);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
|
||||
|
||||
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
|
||||
|
||||
```typescript
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
userId TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, permission),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||
userId TEXT NOT NULL,
|
||||
botId TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, botId),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||
`);
|
||||
```
|
||||
|
||||
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run src/data/permissions.test.ts`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
|
||||
git commit -m "feat(perm): permission store + capability tokens + tables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: requirePermission + requireBotAccess middleware
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/middleware/requirePermission.ts`
|
||||
- Create: `src/web/middleware/requirePermission.test.ts`
|
||||
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
|
||||
|
||||
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
|
||||
|
||||
Change the `declare module` block so `req.user` carries capabilities + bot access:
|
||||
|
||||
```typescript
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: "admin" | "member";
|
||||
capabilities: Set<string>;
|
||||
bots: "all" | Set<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
|
||||
|
||||
- [ ] **Step 2: Write the failing middleware test**
|
||||
|
||||
`src/web/middleware/requirePermission.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { requirePermission, requireBotAccess } from "./requirePermission.js";
|
||||
|
||||
function appWith(user: any) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
|
||||
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
const member = (caps: string[], bots: "all" | string[]) => ({
|
||||
id: "u1", username: "a", role: "member",
|
||||
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
|
||||
});
|
||||
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
|
||||
|
||||
describe("requirePermission", () => {
|
||||
it("401 when unauthenticated", async () => {
|
||||
const app = express();
|
||||
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
|
||||
expect((await request(app).post("/cap")).status).toBe(401);
|
||||
});
|
||||
it("403 when member lacks the capability", async () => {
|
||||
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
|
||||
});
|
||||
it("200 when member has the capability", async () => {
|
||||
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
|
||||
});
|
||||
it("200 for admin regardless of capabilities", async () => {
|
||||
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireBotAccess", () => {
|
||||
it("200 when bots = all", async () => {
|
||||
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
|
||||
});
|
||||
it("200 when botId in allow-list", async () => {
|
||||
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
|
||||
});
|
||||
it("403 when botId not in allow-list", async () => {
|
||||
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
|
||||
});
|
||||
it("200 for admin", async () => {
|
||||
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
|
||||
Expected: FAIL — module `./requirePermission.js` not found.
|
||||
|
||||
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
|
||||
|
||||
```typescript
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
|
||||
export function requirePermission(capability: string): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
};
|
||||
}
|
||||
|
||||
export function requireBotAccess(paramName = "botId"): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
|
||||
const botId = req.params[paramName];
|
||||
if (botId && req.user.bots.has(botId)) { next(); return; }
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
|
||||
Expected: PASS (8 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
|
||||
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Effective-permissions resolver (admin = all)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
|
||||
- Modify: `src/data/permissions.test.ts` (add tests)
|
||||
|
||||
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { resolvePermissionContext } from "./permissions.js";
|
||||
|
||||
describe("resolvePermissionContext", () => {
|
||||
it("admin gets all capabilities and all bots regardless of stored rows", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
const ctx = resolvePermissionContext("admin", "u1", store);
|
||||
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
|
||||
expect(ctx.bots).toBe("all");
|
||||
});
|
||||
it("member reflects stored capabilities + bot access", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
|
||||
const ctx = resolvePermissionContext("member", "u1", store);
|
||||
expect([...ctx.capabilities]).toEqual(["player.control"]);
|
||||
expect(ctx.bots).toEqual(new Set(["b1"]));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify fail**
|
||||
|
||||
Run: `npx vitest run src/data/permissions.test.ts`
|
||||
Expected: FAIL — `resolvePermissionContext` not exported.
|
||||
|
||||
- [ ] **Step 3: Add to `src/data/permissions.ts`**
|
||||
|
||||
```typescript
|
||||
export interface PermissionContext {
|
||||
capabilities: Set<string>;
|
||||
bots: "all" | Set<string>;
|
||||
}
|
||||
|
||||
export function resolvePermissionContext(
|
||||
role: "admin" | "member",
|
||||
userId: string,
|
||||
store: PermissionStore
|
||||
): PermissionContext {
|
||||
if (role === "admin") {
|
||||
return { capabilities: new Set(CAPABILITIES), bots: "all" };
|
||||
}
|
||||
const access = store.getBotAccess(userId);
|
||||
return {
|
||||
capabilities: new Set(store.getCapabilities(userId)),
|
||||
bots: access === "all" ? "all" : new Set(access),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npx vitest run src/data/permissions.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/permissions.ts src/data/permissions.test.ts
|
||||
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/middleware/requireAuth.ts`
|
||||
- Modify: `src/web/api/session.ts`
|
||||
- Modify: `src/web/server.ts`
|
||||
|
||||
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
|
||||
|
||||
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
|
||||
|
||||
```typescript
|
||||
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||
|
||||
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
|
||||
return function requireAuth(req, res, next) {
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
|
||||
req.user = {
|
||||
id: result.userId, username: result.username, role: result.role,
|
||||
capabilities: ctx.capabilities, bots: ctx.bots,
|
||||
};
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `src/web/server.ts`**
|
||||
|
||||
Construct the store next to the others and pass it in:
|
||||
|
||||
```typescript
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
// ...
|
||||
const permissions = createPermissionStore(options.database.db);
|
||||
// ...
|
||||
const requireAuth = createRequireAuth(sessions, permissions);
|
||||
```
|
||||
|
||||
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
|
||||
|
||||
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
|
||||
|
||||
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
|
||||
|
||||
```typescript
|
||||
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
|
||||
res.json({
|
||||
id: validation.userId, username: validation.username, role: validation.role,
|
||||
capabilities: [...ctx.capabilities],
|
||||
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
|
||||
});
|
||||
```
|
||||
|
||||
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
|
||||
|
||||
- [ ] **Step 4: Verify build + existing tests**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
|
||||
|
||||
Run: `npx vitest run src/web`
|
||||
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
|
||||
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Enforce capabilities on the action routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
|
||||
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
|
||||
|
||||
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
|
||||
- member without `player.control` → `POST /api/player/:botId/pause` → 403
|
||||
- member with `player.control` + bot in allow-list → 200 (bot resolves)
|
||||
- member with `player.control` but bot NOT in allow-list → 403
|
||||
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
|
||||
- member without `bot.manage` → `POST /api/bot` → 403
|
||||
- member without `platform.auth` → `POST /api/auth/cookie` → 403
|
||||
- member without `quality` → `POST /api/music/quality` → 403
|
||||
- admin → all 200/allowed
|
||||
|
||||
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
|
||||
|
||||
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
|
||||
|
||||
- [ ] **Step 3: Apply gates.**
|
||||
|
||||
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
|
||||
|
||||
```typescript
|
||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||
|
||||
// after the existing router.use("/:botId", resolveBot):
|
||||
router.use("/:botId", requireBotAccess("botId"));
|
||||
|
||||
const control = requirePermission("player.control");
|
||||
const queue = requirePermission("player.queue");
|
||||
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
|
||||
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
|
||||
// Apply per route, e.g.:
|
||||
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
|
||||
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
|
||||
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
|
||||
```
|
||||
|
||||
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
|
||||
|
||||
`src/web/api/bot.ts` — gate management + per-bot:
|
||||
|
||||
```typescript
|
||||
const manage = requirePermission("bot.manage");
|
||||
router.post("/", manage, ...); // create (no botId)
|
||||
router.put("/:id", manage, requireBotAccess("id"), ...);
|
||||
router.delete("/:id", manage, requireBotAccess("id"), ...);
|
||||
router.post("/:id/start", manage, requireBotAccess("id"), ...);
|
||||
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
|
||||
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
|
||||
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
|
||||
router.post("/settings", manage, ...); // global idle timeout
|
||||
```
|
||||
|
||||
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
|
||||
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
|
||||
|
||||
`src/web/api/music.ts` — gate the one mutating route:
|
||||
`router.post("/quality", requirePermission("quality"), ...)`.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
|
||||
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Filter the bot list for members
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api/bot.ts` (`GET /`)
|
||||
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
|
||||
- Modify: test from Task 5
|
||||
|
||||
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
|
||||
|
||||
- [ ] **Step 2: Run → fail.**
|
||||
|
||||
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
|
||||
|
||||
```typescript
|
||||
const all = getAllBots().map((b) => b.getStatus());
|
||||
const u = req.user!;
|
||||
const bots = u.role === "admin" || u.bots === "all"
|
||||
? all
|
||||
: all.filter((b) => (u.bots as Set<string>).has(b.id));
|
||||
res.json({ bots });
|
||||
```
|
||||
|
||||
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
|
||||
|
||||
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
|
||||
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Management API (GET/PUT permissions) + audit
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/audit.ts` (add action)
|
||||
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
|
||||
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
|
||||
- Create/extend: `src/web/api/users.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
|
||||
|
||||
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
|
||||
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
|
||||
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
|
||||
- `PUT` with an unknown capability token → it is dropped (not stored).
|
||||
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
|
||||
|
||||
- [ ] **Step 3: Run → fail.**
|
||||
|
||||
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
|
||||
|
||||
```typescript
|
||||
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
|
||||
|
||||
router.get("/:id/permissions", (req, res) => {
|
||||
const user = users.findById(req.params.id);
|
||||
if (!user) { res.status(404).json({ error: "not_found" }); return; }
|
||||
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
|
||||
});
|
||||
|
||||
router.put("/:id/permissions", (req, res) => {
|
||||
const user = users.findById(req.params.id);
|
||||
if (!user) { res.status(404).json({ error: "not_found" }); return; }
|
||||
const body = req.body ?? {};
|
||||
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
|
||||
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
|
||||
permissions.setPermissions(user.id, { capabilities: caps, bots });
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: user.id, targetUsername: user.username,
|
||||
action: "user.permissions_changed",
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
```
|
||||
|
||||
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
|
||||
|
||||
```typescript
|
||||
if (created.role === "member") {
|
||||
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
|
||||
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: One-time migration backfill (existing members → full)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
|
||||
- Create: `src/data/permissions-migration.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
|
||||
|
||||
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
|
||||
|
||||
- [ ] **Step 2: Run → fail.**
|
||||
|
||||
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
|
||||
|
||||
```typescript
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
|
||||
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
|
||||
if (!done) {
|
||||
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
|
||||
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||
const tx = db.transaction(() => {
|
||||
for (const m of members) {
|
||||
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
|
||||
insCap.run(m.id, c);
|
||||
}
|
||||
}
|
||||
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
|
||||
});
|
||||
tx();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → pass.**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/database.ts src/data/permissions-migration.test.ts
|
||||
git commit -m "feat(perm): one-time backfill of existing members to full access"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend — session capabilities + helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/composables/useSession.ts`
|
||||
|
||||
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
|
||||
- [ ] **Step 2:** Add computed helpers:
|
||||
|
||||
```typescript
|
||||
function can(cap: string): boolean {
|
||||
const u = currentUser.value;
|
||||
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
|
||||
}
|
||||
function canControlBot(botId: string): boolean {
|
||||
const u = currentUser.value;
|
||||
if (!u) return false;
|
||||
if (u.role === 'admin' || u.bots === 'all') return true;
|
||||
return Array.isArray(u.bots) && u.bots.includes(botId);
|
||||
}
|
||||
```
|
||||
|
||||
Export `can` and `canControlBot` from the composable.
|
||||
|
||||
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
|
||||
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — gate UI by capability + filter bots
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
|
||||
|
||||
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
|
||||
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
|
||||
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
|
||||
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
|
||||
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Frontend — admin permission editor
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Settings.vue` (User Management section)
|
||||
|
||||
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
|
||||
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
|
||||
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
|
||||
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `npx tsc --noEmit` → exit 0
|
||||
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
|
||||
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||
- [ ] `npm run build` → succeeds
|
||||
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
|
||||
|
||||
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
|
||||
|
||||
## Out of scope (separate PRs, per spec)
|
||||
|
||||
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Auto-pause on Empty Channel — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
|
||||
|
||||
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
|
||||
|
||||
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pure occupancy-decision function
|
||||
|
||||
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
|
||||
|
||||
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
describe("decideOccupancyAction", () => {
|
||||
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
|
||||
it("pauses when empty while playing and enabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||
});
|
||||
it("does not pause when the feature is disabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when idle (nothing playing)", () => {
|
||||
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when already paused", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("resumes when re-populated and we auto-paused", () => {
|
||||
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||
});
|
||||
it("does NOT resume a user-paused track on re-population", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("does nothing when re-populated and already playing", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
|
||||
|
||||
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
|
||||
|
||||
```typescript
|
||||
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||
export type OccupancyAction = "pause" | "resume" | "none";
|
||||
|
||||
/**
|
||||
* Decide what auto-pause should do given the channel occupancy.
|
||||
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||
*/
|
||||
export function decideOccupancyAction(
|
||||
playerState: PlayerStateName,
|
||||
autoPaused: boolean,
|
||||
enabled: boolean,
|
||||
userCount: number,
|
||||
): OccupancyAction {
|
||||
const empty = userCount <= 0;
|
||||
if (empty) {
|
||||
if (enabled && playerState === "playing") return "pause";
|
||||
return "none";
|
||||
}
|
||||
if (autoPaused && playerState === "paused") return "resume";
|
||||
return "none";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
|
||||
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
|
||||
|
||||
**Files:** Modify `src/bot/instance.ts`.
|
||||
|
||||
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
|
||||
|
||||
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
|
||||
|
||||
```typescript
|
||||
import { decideOccupancyAction } from "./auto-pause.js";
|
||||
|
||||
private handleOccupancy(userCount: number): void {
|
||||
// idle-disconnect (unchanged behavior)
|
||||
if (userCount <= 0) this._scheduleIdleCheck();
|
||||
else this._cancelIdleTimer();
|
||||
|
||||
// auto-pause
|
||||
const action = decideOccupancyAction(
|
||||
this.player.getState() as "idle" | "playing" | "paused",
|
||||
this.autoPaused,
|
||||
this.config.autoPauseOnEmpty,
|
||||
userCount,
|
||||
);
|
||||
if (action === "pause") {
|
||||
this.player.pause();
|
||||
this.autoPaused = true;
|
||||
this.emit("stateChange");
|
||||
} else if (action === "resume") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
|
||||
|
||||
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
|
||||
|
||||
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
|
||||
|
||||
```typescript
|
||||
updateAutoPause(enabled: boolean): void {
|
||||
this.config.autoPauseOnEmpty = enabled;
|
||||
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
|
||||
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||
this.player.resume();
|
||||
this.autoPaused = false;
|
||||
this.emit("stateChange");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
|
||||
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Re-emit TS member events for instant reaction
|
||||
|
||||
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
|
||||
|
||||
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
|
||||
|
||||
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
|
||||
|
||||
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
|
||||
|
||||
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
|
||||
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: API wiring for the toggle
|
||||
|
||||
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
|
||||
|
||||
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
|
||||
|
||||
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
|
||||
|
||||
- [ ] **Step 2 — run, expect fail.**
|
||||
|
||||
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
|
||||
|
||||
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
|
||||
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend toggle in Settings
|
||||
|
||||
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
|
||||
|
||||
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
|
||||
|
||||
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
|
||||
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
- [ ] `npx tsc --noEmit` → 0
|
||||
- [ ] `npx vitest run src/` → all pass
|
||||
- [ ] `cd web && npx vue-tsc --noEmit` → 0
|
||||
- [ ] `npm run build` → succeeds
|
||||
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
|
||||
|
||||
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
|
||||
|
||||
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Store scope state + pure resolve helper (with test)
|
||||
|
||||
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
|
||||
|
||||
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
|
||||
|
||||
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
|
||||
|
||||
```typescript
|
||||
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||
* a stale/forbidden id never locks the UI). */
|
||||
export function resolveScopedBot(
|
||||
requestedId: string | null | undefined,
|
||||
knownBotIds: readonly string[],
|
||||
): string | null {
|
||||
if (!requestedId) return null;
|
||||
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||
}
|
||||
```
|
||||
|
||||
`web/src/stores/scope.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveScopedBot } from "./scope.js";
|
||||
|
||||
describe("resolveScopedBot", () => {
|
||||
it("returns null when no id requested", () => {
|
||||
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||
});
|
||||
it("returns the id when it exists in the bot list", () => {
|
||||
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||
});
|
||||
it("clears (null) when the requested id is not a known bot", () => {
|
||||
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
|
||||
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
|
||||
|
||||
- [ ] **Step 3 — implement the helper** (code above).
|
||||
|
||||
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
|
||||
- state: `scopedBotId: null as string | null`.
|
||||
- getter: `isScoped: (state) => state.scopedBotId !== null`.
|
||||
- actions:
|
||||
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
|
||||
- `clearScope()` → `this.scopedBotId = null;`.
|
||||
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
|
||||
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
|
||||
|
||||
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||
|
||||
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
|
||||
|
||||
**Files:** Modify `web/src/router/index.ts`.
|
||||
|
||||
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
|
||||
|
||||
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
|
||||
|
||||
```typescript
|
||||
const store = usePlayerStore();
|
||||
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
|
||||
if (qBot) {
|
||||
// entering/with a scope in the URL — store will validate against bots later
|
||||
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
|
||||
return next();
|
||||
}
|
||||
if (store.scopedBotId) {
|
||||
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
|
||||
if (to.query.bot !== store.scopedBotId) {
|
||||
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
|
||||
}
|
||||
}
|
||||
return next();
|
||||
```
|
||||
|
||||
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
|
||||
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
|
||||
|
||||
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: BotRedirect seeds the URL scope
|
||||
|
||||
**Files:** Modify `web/src/views/BotRedirect.vue`.
|
||||
|
||||
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
|
||||
|
||||
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
|
||||
- keep the fetchBots + existence check,
|
||||
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
|
||||
- if not found: unchanged (show "机器人不存在或未加载").
|
||||
|
||||
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Navbar lock UI + apply-scope-on-load
|
||||
|
||||
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
|
||||
|
||||
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
|
||||
|
||||
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
|
||||
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
|
||||
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
|
||||
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
|
||||
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
|
||||
When not scoped: behavior unchanged.
|
||||
|
||||
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
|
||||
|
||||
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
|
||||
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
|
||||
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
|
||||
- [ ] `npm run build` → succeeds
|
||||
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
|
||||
|
||||
## Notes
|
||||
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
|
||||
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
|
||||
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,840 @@
|
||||
# TeamSpeak chat-command permission control — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Gate a fixed set of "admin" TeamSpeak chat commands (`stop`, `clear`, `remove`, `move`, `vol`, `mode`) behind configured TS server-group IDs, opt-in and backward-compatible, configurable from the WebUI and `config.json`.
|
||||
|
||||
**Architecture:** A pure helper `canRunCommand(name, invokerGroups, adminGroups)` decides allow/deny. The chat handler `handleTextMessage` (NOT the WebUI-shared `executeCommand`) consults it before executing, performs a best-effort group lookup when the sender's groups weren't delivered with the event, fails closed, and replies on deny. The privileged groups live in the already-declared `config.adminGroups`, surfaced through the existing `GET/POST /api/bot/settings` endpoints and an admin-only Settings.vue section.
|
||||
|
||||
**Tech Stack:** Node 20, TypeScript (ESM), Express 5, Vitest + supertest (backend), Vue 3 + `vue-tsc` (frontend), `@honeybbq/teamspeak-client`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **ESM import specifiers:** every relative import ends in `.js` even in `.ts` files (e.g. `import { canRunCommand } from "./commands.js"`).
|
||||
- **Admin command set (exact, single source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. (Note: `follow` is intentionally NOT admin — it becomes public.)
|
||||
- **Enforcement is opt-in / backward-compatible:** `config.adminGroups === []` (the default) ⇒ no enforcement; admin commands stay open to everyone exactly as today.
|
||||
- **Fail closed:** an admin command, with enforcement on, whose sender groups cannot be determined (even after fallback) is **denied**.
|
||||
- **Group-id normalization:** `invokerGroups` are strings, `adminGroups` are numbers — compare as the same type so `"6"` matches `6`.
|
||||
- **Denial reply text (exact):** `⛔ 需要管理员权限(该命令仅限管理员服务器组)`.
|
||||
- **`adminGroups` validation:** array of non-negative integers; filter out everything else; ignore a non-array value entirely.
|
||||
- **Live config:** `BotInstance` shares the same `config` object the router mutates; the gate reads `this.config.adminGroups` live (no restart, no propagation call).
|
||||
- **Per-task tests:** run `npx vitest run <file>` (targets `.ts` directly). Before any full `npm test`, run `rm -rf dist` first — a stale untracked `dist/` makes vitest double-run compiled `.test.js` copies (known environment quirk). The repo path contains spaces (`/c/Users/saopig1/Music/teamspeak music bot`) — quote it.
|
||||
- **Frontend type-check:** `cd web && npx vue-tsc --noEmit` (must be clean).
|
||||
- **TDD + frequent commits:** every task is red→green→commit. Keep project `tsc`/`vitest` green after each task.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `canRunCommand` helper + admin-set as single source of truth
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/commands.ts` (lines 8-16 sets; line 59-61 `isAdminCommand`)
|
||||
- Test: `src/bot/commands.test.ts` (append a new `describe` block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks.
|
||||
- Produces:
|
||||
- `export const ADMIN_COMMANDS: Set<string>` = `{stop, clear, remove, move, vol, mode}`
|
||||
- `export function isAdminCommand(commandName: string): boolean` (unchanged signature)
|
||||
- `export function canRunCommand(commandName: string, invokerGroups: readonly (string | number)[], adminGroups: readonly number[]): boolean` — consumed by Task 3.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `src/bot/commands.test.ts`:
|
||||
|
||||
```ts
|
||||
import { canRunCommand, isAdminCommand } from "./commands.js";
|
||||
|
||||
describe("isAdminCommand classification", () => {
|
||||
it("treats stop/clear/remove/move/vol/mode as admin", () => {
|
||||
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
|
||||
expect(isAdminCommand(c)).toBe(true);
|
||||
}
|
||||
});
|
||||
it("treats follow and play as NOT admin", () => {
|
||||
expect(isAdminCommand("follow")).toBe(false);
|
||||
expect(isAdminCommand("play")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canRunCommand", () => {
|
||||
it("allows any public command regardless of groups", () => {
|
||||
expect(canRunCommand("play", [], [6])).toBe(true);
|
||||
expect(canRunCommand("follow", [], [6])).toBe(true);
|
||||
});
|
||||
it("allows admin command when enforcement is off (empty adminGroups)", () => {
|
||||
expect(canRunCommand("stop", [], [])).toBe(true);
|
||||
});
|
||||
it("allows admin command when an invoker group matches (string vs number)", () => {
|
||||
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
|
||||
expect(canRunCommand("stop", [6], [6])).toBe(true);
|
||||
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
|
||||
});
|
||||
it("denies admin command when no invoker group matches", () => {
|
||||
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
|
||||
});
|
||||
it("denies admin command when invoker has no groups and enforcement is on", () => {
|
||||
expect(canRunCommand("clear", [], [6])).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run "src/bot/commands.test.ts"`
|
||||
Expected: FAIL — `canRunCommand` is not exported / not a function.
|
||||
|
||||
- [ ] **Step 3: Implement the helper and tighten the admin set**
|
||||
|
||||
In `src/bot/commands.ts`, delete the dead `PUBLIC_COMMANDS` export (nothing imports it; the admin set is the sole source of truth), set `ADMIN_COMMANDS` to the exact spec set (drop `follow`), and add `canRunCommand`. The file becomes:
|
||||
|
||||
```ts
|
||||
export interface ParsedCommand {
|
||||
name: string;
|
||||
args: string;
|
||||
rawArgs: string[];
|
||||
flags: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
|
||||
* for which commands the permission gate restricts; reclassifying a command is
|
||||
* a one-line edit here. Everything not in this set is public.
|
||||
*/
|
||||
export const ADMIN_COMMANDS = new Set([
|
||||
"stop", "clear", "remove", "move", "vol", "mode",
|
||||
]);
|
||||
|
||||
export function parseCommand(
|
||||
message: string,
|
||||
prefix: string,
|
||||
aliases: Record<string, string> = {},
|
||||
): ParsedCommand | null {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed.startsWith(prefix)) return null;
|
||||
|
||||
const withoutPrefix = trimmed.slice(prefix.length);
|
||||
if (!withoutPrefix) return null;
|
||||
|
||||
const parts = withoutPrefix.split(/\s+/);
|
||||
let name = parts[0].toLowerCase();
|
||||
|
||||
if (aliases[name]) {
|
||||
name = aliases[name];
|
||||
}
|
||||
|
||||
const flags = new Set<string>();
|
||||
const argParts: string[] = [];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (
|
||||
parts[i].startsWith("-") &&
|
||||
parts[i].length === 2 &&
|
||||
/[a-zA-Z]/.test(parts[i][1])
|
||||
) {
|
||||
flags.add(parts[i][1].toLowerCase());
|
||||
} else {
|
||||
argParts.push(parts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
args: argParts.join(" "),
|
||||
rawArgs: argParts,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAdminCommand(commandName: string): boolean {
|
||||
return ADMIN_COMMANDS.has(commandName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a chat command may run, given the invoker's TS server groups
|
||||
* and the configured admin groups. Pure + synchronous so it is trivially unit
|
||||
* tested and reused by the async gate in BotInstance.
|
||||
*
|
||||
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
|
||||
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
|
||||
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
|
||||
* strings before comparison so "6" matches 6.
|
||||
*/
|
||||
export function canRunCommand(
|
||||
commandName: string,
|
||||
invokerGroups: readonly (string | number)[],
|
||||
adminGroups: readonly number[],
|
||||
): boolean {
|
||||
if (!isAdminCommand(commandName)) return true;
|
||||
if (adminGroups.length === 0) return true;
|
||||
const admin = new Set(adminGroups.map((g) => String(g)));
|
||||
return invokerGroups.some((g) => admin.has(String(g)));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run "src/bot/commands.test.ts"`
|
||||
Expected: PASS (parser tests + the new classification/canRunCommand tests).
|
||||
|
||||
- [ ] **Step 5: Verify nothing else imported the deleted symbol**
|
||||
|
||||
Run: `grep -rn "PUBLIC_COMMANDS" src/`
|
||||
Expected: no matches (confirms the deletion is safe).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/bot/commands.ts" "src/bot/commands.test.ts"
|
||||
git commit -m "feat(commands): add canRunCommand gate helper + admin-set source of truth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Surface `invokerGroups` on `TS3TextMessage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ts-protocol/client.ts` (interface lines 58-64; mapping lines 205-214)
|
||||
- Test: `src/ts-protocol/text-message.test.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks.
|
||||
- Produces:
|
||||
- `TS3TextMessage` gains `invokerGroups: string[]`.
|
||||
- `export function toTS3TextMessage(msg: TextMessage): TS3TextMessage` — a pure mapper, used by the `textMessage` event handler and unit-testable. Consumed (the field) by Task 3.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/ts-protocol/text-message.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toTS3TextMessage } from "./client.js";
|
||||
import type { TextMessage } from "@honeybbq/teamspeak-client";
|
||||
|
||||
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
|
||||
return {
|
||||
invokerName: "Alice",
|
||||
invokerUID: "uid-abc",
|
||||
message: "!stop",
|
||||
invokerGroups: ["6", "8"],
|
||||
targetMode: 2,
|
||||
targetID: 0n,
|
||||
invokerID: 5,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toTS3TextMessage", () => {
|
||||
it("maps core fields and stringifies invokerID", () => {
|
||||
const r = toTS3TextMessage(makeMsg());
|
||||
expect(r.invokerName).toBe("Alice");
|
||||
expect(r.invokerId).toBe("5");
|
||||
expect(r.invokerUid).toBe("uid-abc");
|
||||
expect(r.message).toBe("!stop");
|
||||
expect(r.targetMode).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves the sender's server groups", () => {
|
||||
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
|
||||
});
|
||||
|
||||
it("defaults missing invokerGroups to an empty array", () => {
|
||||
const partial = {
|
||||
invokerName: "Bob",
|
||||
invokerUID: "u",
|
||||
message: "!stop",
|
||||
targetMode: 1,
|
||||
targetID: 0n,
|
||||
invokerID: 7,
|
||||
} as unknown as TextMessage;
|
||||
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
|
||||
Expected: FAIL — `toTS3TextMessage` is not exported.
|
||||
|
||||
- [ ] **Step 3: Add the field and the pure mapper, and use it in the handler**
|
||||
|
||||
In `src/ts-protocol/client.ts`, extend the interface (add `invokerGroups`):
|
||||
|
||||
```ts
|
||||
export interface TS3TextMessage {
|
||||
invokerName: string;
|
||||
invokerId: string;
|
||||
invokerUid: string;
|
||||
message: string;
|
||||
targetMode: number; // 1=private, 2=channel, 3=server
|
||||
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
|
||||
}
|
||||
```
|
||||
|
||||
Add the pure mapper just below the interface (still above the `TS3Client` class):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
|
||||
* sender's TS server groups), which the library populates only when the sender
|
||||
* is in the bot's client-view cache; otherwise it is []. Used by the chat
|
||||
* command permission gate.
|
||||
*/
|
||||
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
|
||||
return {
|
||||
invokerName: msg.invokerName,
|
||||
invokerId: String(msg.invokerID),
|
||||
invokerUid: msg.invokerUID,
|
||||
message: msg.message,
|
||||
targetMode: msg.targetMode,
|
||||
invokerGroups: msg.invokerGroups ?? [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Replace the inline mapping inside `this.client.on("textMessage", ...)` (currently lines 205-214) with a call to the mapper:
|
||||
|
||||
```ts
|
||||
this.client.on("textMessage", (msg: TextMessage) => {
|
||||
this.emit("textMessage", toTS3TextMessage(msg));
|
||||
});
|
||||
```
|
||||
|
||||
(`TextMessage` is already imported at the top of the file.)
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/ts-protocol/client.ts" "src/ts-protocol/text-message.test.ts"
|
||||
git commit -m "feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Permission gate in `handleTextMessage` (fallback lookup + fail-closed + denial reply)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/bot/instance.ts` (imports lines 10-14; add a module constant; `handleTextMessage` lines 317-349; add two private methods)
|
||||
- Test: `src/bot/instance.test.ts` (append a new `describe` block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:
|
||||
- `canRunCommand(commandName, invokerGroups, adminGroups)` from `./commands.js` (Task 1).
|
||||
- `TS3TextMessage.invokerGroups: string[]` (Task 2).
|
||||
- Existing `this.tsClient.getClientsInChannel(): Promise<ClientInfo[]>` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these).
|
||||
- Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise<void>`.
|
||||
- Produces:
|
||||
- `export const COMMAND_DENIED_MESSAGE: string` (exported so the test can assert it).
|
||||
- Private `isCommandAllowed(commandName, msg)` and `lookupInvokerGroups(invokerId)` (exercised via prototype in the test).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `src/bot/instance.test.ts`:
|
||||
|
||||
```ts
|
||||
import { vi } from "vitest";
|
||||
import { COMMAND_DENIED_MESSAGE } from "./instance.js";
|
||||
import type { TS3TextMessage } from "../ts-protocol/client.js";
|
||||
|
||||
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
|
||||
* The gate methods live on the prototype and are attached here so calls like
|
||||
* `this.isCommandAllowed(...)` resolve against this same object. */
|
||||
function makeGateCtx(opts: {
|
||||
adminGroups?: number[];
|
||||
clients?: Array<{ id: number; serverGroups: string[] }>;
|
||||
}) {
|
||||
const ctx: any = {
|
||||
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
tsClient: {
|
||||
sendTextMessage: vi.fn(async () => {}),
|
||||
getClientsInChannel: vi.fn(async () => opts.clients ?? []),
|
||||
},
|
||||
executeCommand: vi.fn(async () => null),
|
||||
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
|
||||
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
|
||||
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
|
||||
}
|
||||
|
||||
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
|
||||
this: unknown,
|
||||
msg: TS3TextMessage,
|
||||
) => Promise<void>;
|
||||
|
||||
describe("BotInstance.handleTextMessage — command permission gate", () => {
|
||||
it("runs a public command even with enforcement on", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!play 晴天"));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("runs an admin command when enforcement is off (empty adminGroups)", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop"));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs an admin command when the event carried a matching group", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed
|
||||
});
|
||||
|
||||
it("denies an admin command when known groups do not match (no fallback, with reply)", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("falls back to a group lookup when the event carried no groups, and allows on match", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
|
||||
expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed when the fallback finds the client but no matching group", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
|
||||
it("fails closed when the fallback cannot find the client at all", async () => {
|
||||
const ctx = makeGateCtx({ adminGroups: [6], clients: [] });
|
||||
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
|
||||
expect(ctx.executeCommand).not.toHaveBeenCalled();
|
||||
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run "src/bot/instance.test.ts"`
|
||||
Expected: FAIL — `COMMAND_DENIED_MESSAGE` is not exported; `isCommandAllowed`/`lookupInvokerGroups` are undefined.
|
||||
|
||||
- [ ] **Step 3: Implement the gate**
|
||||
|
||||
In `src/bot/instance.ts`, change the commands import (lines 10-14) from `isAdminCommand` to `canRunCommand`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseCommand,
|
||||
canRunCommand,
|
||||
type ParsedCommand,
|
||||
} from "./commands.js";
|
||||
```
|
||||
|
||||
Add a module-level constant just after the imports (above `export interface BotInstanceOptions`):
|
||||
|
||||
```ts
|
||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||
```
|
||||
|
||||
Replace `handleTextMessage` (lines 317-349) so the dead stub becomes the real gate:
|
||||
|
||||
```ts
|
||||
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
||||
const parsed = parseCommand(
|
||||
msg.message,
|
||||
this.config.commandPrefix,
|
||||
this.config.commandAliases
|
||||
);
|
||||
if (!parsed) return;
|
||||
|
||||
if (!(await this.isCommandAllowed(parsed.name, msg))) {
|
||||
this.logger.info(
|
||||
{ command: parsed.name, invoker: msg.invokerName },
|
||||
"Command denied: invoker not in adminGroups"
|
||||
);
|
||||
try {
|
||||
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
|
||||
} catch (sendErr) {
|
||||
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{ command: parsed.name, args: parsed.args, invoker: msg.invokerName },
|
||||
"Command received"
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.executeCommand(parsed, msg);
|
||||
if (response) {
|
||||
await this.tsClient.sendTextMessage(response);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||
try {
|
||||
await this.tsClient.sendTextMessage(
|
||||
`Error: ${(err as Error).message}`
|
||||
);
|
||||
} catch (sendErr) {
|
||||
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a chat command may run for this sender. Reads adminGroups
|
||||
* live from this.config (the router mutates the same object). Only performs
|
||||
* the async group lookup when the synchronous decision is "deny because the
|
||||
* event carried no groups" — i.e. an admin command, enforcement on, and
|
||||
* empty invokerGroups. Fails closed if groups remain undeterminable.
|
||||
*/
|
||||
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
|
||||
const adminGroups = this.config.adminGroups;
|
||||
if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true;
|
||||
// Here: admin command, enforcement on, and the provided groups did not match.
|
||||
// If the event actually carried groups, this is a genuine deny — no lookup.
|
||||
if (msg.invokerGroups.length > 0) return false;
|
||||
// Groups unknown (sender not in the view cache): one targeted lookup, then
|
||||
// re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown.
|
||||
const groups = await this.lookupInvokerGroups(msg.invokerId);
|
||||
return canRunCommand(commandName, groups, adminGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort lookup of a sender's server groups by client id, via the
|
||||
* channel client list (whose entries already carry parsed serverGroups).
|
||||
* Returns [] when the client can't be found or the query fails (→ deny).
|
||||
*/
|
||||
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
|
||||
const clid = Number(invokerId);
|
||||
if (!Number.isFinite(clid) || clid <= 0) return [];
|
||||
try {
|
||||
const clients = await this.tsClient.getClientsInChannel();
|
||||
const match = clients.find((c) => c.id === clid);
|
||||
return match?.serverGroups ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the gate tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run "src/bot/instance.test.ts"`
|
||||
Expected: PASS (existing `runExclusive` tests + the 7 new gate tests).
|
||||
|
||||
- [ ] **Step 5: Confirm the live-config invariant**
|
||||
|
||||
Confirm `BotInstance` reads `adminGroups` from the shared, mutable config — not a copy. The constructor stores `this.config = options.config` (line 91 region) and the router (`src/web/api/bot.ts`) mutates that same object; no propagation call is needed. Quick check:
|
||||
|
||||
Run: `grep -n "this.config = options.config\|this.config.adminGroups" "src/bot/instance.ts"`
|
||||
Expected: shows the assignment and the gate read (proves the gate uses the live reference).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/bot/instance.ts" "src/bot/instance.test.ts"
|
||||
git commit -m "feat(bot): gate admin chat commands on adminGroups with fallback + deny reply"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Read/write `adminGroups` in the settings endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api/bot.ts` (GET `/settings` lines 35-41; POST `/settings` lines 45-97)
|
||||
- Test: `src/web/api/bot.test.ts` (append `it` cases to the first `describe("bot router /settings", …)` block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `config.adminGroups: number[]` (already declared in `src/data/config.ts`, default `[]`).
|
||||
- Produces: `GET /api/bot/settings` returns `adminGroups: number[]`; `POST /api/bot/settings` accepts, validates, persists, and echoes `adminGroups`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append these `it` cases inside the existing first `describe("bot router /settings", …)` block in `src/web/api/bot.test.ts` (it already wires `app`, `config`, and an admin `cookie`):
|
||||
|
||||
```ts
|
||||
it("GET /settings includes adminGroups reflecting config", async () => {
|
||||
config.adminGroups = [6, 8];
|
||||
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.adminGroups).toEqual([6, 8]);
|
||||
});
|
||||
|
||||
it("POST /settings persists a validated adminGroups and GET returns it", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ adminGroups: [6, 8] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.adminGroups).toEqual([6, 8]);
|
||||
expect(config.adminGroups).toEqual([6, 8]);
|
||||
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||
expect(followUp.body.adminGroups).toEqual([6, 8]);
|
||||
});
|
||||
|
||||
it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ adminGroups: [6, -1, 2.5, "x", 8] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(config.adminGroups).toEqual([6, 8]);
|
||||
});
|
||||
|
||||
it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => {
|
||||
config.adminGroups = [6];
|
||||
const res = await request(app)
|
||||
.post("/api/bot/settings")
|
||||
.set("Cookie", cookie)
|
||||
.send({ adminGroups: "6" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(config.adminGroups).toEqual([6]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run "src/web/api/bot.test.ts"`
|
||||
Expected: FAIL — `res.body.adminGroups` is `undefined`; the POST does not persist `adminGroups`.
|
||||
|
||||
- [ ] **Step 3: Extend the GET handler**
|
||||
|
||||
In `src/web/api/bot.ts`, add `adminGroups` to the GET `/settings` response (the handler at lines 35-41):
|
||||
|
||||
```ts
|
||||
router.get("/settings", requireNotGuest, (_req, res) => {
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend the POST handler**
|
||||
|
||||
In the POST `/settings` handler: (a) pull `adminGroups` out of `req.body`; (b) validate + assign before `saveConfig`; (c) echo it in the response. Change the destructuring line (46):
|
||||
|
||||
```ts
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
|
||||
```
|
||||
|
||||
Add this block just before `saveConfig(configPath, config);` (line 77):
|
||||
|
||||
```ts
|
||||
if (Array.isArray(adminGroups)) {
|
||||
config.adminGroups = adminGroups.filter(
|
||||
(g: unknown): g is number =>
|
||||
typeof g === "number" && Number.isInteger(g) && g >= 0,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Add `adminGroups` to BOTH `res.json({ … })` bodies in this handler (the success response near line 92, and — if present — keep them consistent):
|
||||
|
||||
```ts
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
adminGroups: config.adminGroups ?? [],
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run "src/web/api/bot.test.ts"`
|
||||
Expected: PASS (existing settings/guest-mode tests + the 4 new adminGroups tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/web/api/bot.ts" "src/web/api/bot.test.ts"
|
||||
git commit -m "feat(api): read/write adminGroups in bot settings endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Admin-only "命令权限" section in Settings.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/views/Settings.vue` (template: add a section after the Guest Mode section, before the Bot Profile section ~line 506; script: add state + handlers near the guest-mode block ~line 1093; hydrate in `loadIdleTimeout` ~line 1024)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/bot/settings` → `adminGroups: number[]`; `POST /api/bot/settings` with `{ adminGroups: number[] }` (Task 4). Existing `session.isAdmin.value`.
|
||||
- Produces: UI only.
|
||||
|
||||
- [ ] **Step 1: Add the template section**
|
||||
|
||||
In `web/src/views/Settings.vue`, insert this `<section>` immediately AFTER the closing `</section>` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `<!-- Bot Profile … -->` section:
|
||||
|
||||
```html
|
||||
<!-- Command Permissions (admin only) -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">命令权限</h2>
|
||||
<p class="profile-section-hint">
|
||||
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
|
||||
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
|
||||
</p>
|
||||
<div class="setting-row">
|
||||
<div class="prefix-input-wrap">
|
||||
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
|
||||
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
|
||||
{{ adminGroupsSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the script state + handlers**
|
||||
|
||||
In the `<script setup>` block, add this just after the guest-mode block (after `saveGuestMode` closes, ~line 1093):
|
||||
|
||||
```ts
|
||||
// --- Command permissions (admin only) ---
|
||||
const adminGroupsText = ref('');
|
||||
const adminGroupsSaving = ref(false);
|
||||
|
||||
function applyAdminGroupsFromServer(groups: unknown) {
|
||||
if (Array.isArray(groups)) {
|
||||
adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdminGroups(text: string): number[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => Number.isInteger(n) && n >= 0);
|
||||
}
|
||||
|
||||
async function saveAdminGroups() {
|
||||
adminGroupsSaving.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) });
|
||||
applyAdminGroupsFromServer(res.data?.adminGroups);
|
||||
} catch { /* ignore */ } finally {
|
||||
adminGroupsSaving.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Hydrate on load**
|
||||
|
||||
In `loadIdleTimeout` (the existing function ~lines 1024-1031), add the hydrate call alongside `applyGuestModeFromServer`:
|
||||
|
||||
```ts
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
applyAdminGroupsFromServer(res.data.adminGroups);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check the frontend**
|
||||
|
||||
Run: `cd "web" && npx vue-tsc --noEmit`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "web/src/views/Settings.vue"
|
||||
git commit -m "feat(web): admin-only command-permission (adminGroups) settings section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Document the feature in the README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (docs).
|
||||
- Produces: user-facing documentation of the feature + how to find TS server-group IDs.
|
||||
|
||||
- [ ] **Step 1: Locate the insertion point**
|
||||
|
||||
Run: `grep -n "游客模式\|Guest\|权限\|adminGroups" "README.md"`
|
||||
Expected: shows the guest-mode / permissions area. Insert the new subsection immediately after the guest-mode documentation block (or, if there is a dedicated permissions/features section, at its end).
|
||||
|
||||
- [ ] **Step 2: Add the documentation block**
|
||||
|
||||
Insert this markdown at the chosen point:
|
||||
|
||||
```markdown
|
||||
### TeamSpeak 命令权限(管理类命令限制)
|
||||
|
||||
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
|
||||
|
||||
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
|
||||
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
|
||||
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
|
||||
|
||||
**配置方式**
|
||||
|
||||
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
|
||||
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
|
||||
|
||||
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限」的提示。
|
||||
|
||||
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
|
||||
|
||||
**如何查看服务器组 ID**
|
||||
|
||||
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity-check the docs render**
|
||||
|
||||
Run: `grep -n "命令权限\|adminGroups" "README.md"`
|
||||
Expected: shows the newly added section.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add "README.md"
|
||||
git commit -m "docs: document TeamSpeak chat-command permission control"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification (after all tasks)
|
||||
|
||||
- [ ] Remove stale compiled output, then run the full suite:
|
||||
|
||||
```bash
|
||||
rm -rf dist
|
||||
npm test
|
||||
```
|
||||
Expected: all tests pass (the new `canRunCommand`, `toTS3TextMessage`, gate, and `adminGroups` settings tests included).
|
||||
|
||||
- [ ] Full build (backend `tsc` + frontend `vue-tsc` + vite):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: SUCCESS (no type errors).
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,520 @@
|
||||
# Spotify Source — Stage 4 (Polish, Config UI, Docs) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete the Spotify source's last mile — make it user-configurable and user-authorizable from the web UI, document it (with the required safety warnings), and land the deferred OAuth-robustness hardening.
|
||||
|
||||
**Architecture:** The Stage-3 OAuth endpoints (`/api/spotify/{login,callback,status}`) and the single shared `SpotifyOAuth` already exist but are unreachable from the UI. Stage 4 adds: (1) a `spotify` block to the `/api/bot/settings` config API (secret masked); (2) a resolved-backend indicator on `/status`; (3) OAuth refresh/verifier hardening; (4) the approved Settings "Connect Spotify" card (spec §8); (5) the README Spotify section (spec §11/§12). Deferred (documented, NOT built this stage): runtime binary auto-download, connect play-not-reflected diagnostics, extra watchdog.
|
||||
|
||||
**Tech Stack:** TypeScript (ESM, `.js` specifiers), Express, Vitest (root config also runs `web/src/**/*.test.ts` — stores/composables only; there is NO `.vue` component-test harness), Vue 3 + `<script setup>` + Pinia, `vue-tsc` (web build gate).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Safe-by-default (spec §2/§7):** Spotify stays inert unless `enabled` AND authorized AND a resolvable binary. Nothing in this stage may change behavior when `spotify.enabled === false`.
|
||||
- **Never expose secrets (spec §2/§7):** `clientSecret` is the operator's own value. The config GET API MUST NOT return the raw `clientSecret` — return only a boolean `hasClientSecret`. POST overwrites `clientSecret` only when a non-empty string is supplied (blank/omitted = unchanged). No bundled credentials, no shared Developer app.
|
||||
- **Permissions:** config writes gated by `requirePermission("bot.manage")`; reads by `requireNotGuest`; the OAuth login card is additionally shown only to `can('platform.auth')` / admins — mirror the existing Settings gating.
|
||||
- **Validation mirrors `src/data/config.ts`:** `backend` ∈ `{"auto","go-librespot","librespot"}`; `bitrate` ∈ `{96,160,320}`; `deviceName` non-empty trimmed string (else keep prior); strings coerced/guarded. Reuse the exact value sets.
|
||||
- **Language:** README additions are in Chinese (repo is Chinese-only), matching existing heading style (`## 功能特性` etc.).
|
||||
- **Licensing (spec §9):** go-librespot is GPL-3.0 — the README must include its license note + a source offer; the bot ships NO binary (source-only Rust librespot; Linux-only go-librespot assets).
|
||||
- **Honesty:** live audio / Connect control / real OAuth round-trip remain NOT verifiable here (need Premium + real binaries + a real account). "Done" = unit-tested (mocked process/HTTP/FS), `tsc --noEmit` clean, `cd web && npm run build` clean, full suite green, reviewed. Never claim audio works.
|
||||
- **Branch:** all work on `feat/spotify-audio`. Do NOT create per-task branches.
|
||||
- **Full suite command:** `npx vitest run --no-file-parallelism` (avoids the users.test.ts bcrypt LOAD flake). Typecheck: `npx tsc --noEmit`. Web build: `cd web && npm run build`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `/api/bot/settings` reads & writes the `spotify` block (secret masked)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api/bot.ts` (GET `/settings` response + POST `/settings` destructure/validate/echo)
|
||||
- Test: `src/web/api/bot.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `config.spotify: SpotifyConfig` (`{ enabled, backend, clientId, clientSecret, deviceName, bitrate }`), `saveConfig(configPath, config)`, `requirePermission("bot.manage")`, `requireNotGuest` — all already imported/used in this file.
|
||||
- Produces (new response shape on GET+POST, both add a `spotify` key):
|
||||
```ts
|
||||
// masked view — NEVER includes clientSecret
|
||||
spotify: {
|
||||
enabled: boolean;
|
||||
backend: "auto" | "go-librespot" | "librespot";
|
||||
clientId: string;
|
||||
deviceName: string;
|
||||
bitrate: number;
|
||||
hasClientSecret: boolean; // whether a non-empty secret is stored
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.1: Write failing tests.** Add to `src/web/api/bot.test.ts` (mirror the existing settings tests — reuse their app/harness + auth stubs). Cover:
|
||||
1. GET `/api/bot/settings` includes a `spotify` object with `enabled/backend/clientId/deviceName/bitrate/hasClientSecret` and does NOT include a `clientSecret` key. With a stored secret, `hasClientSecret === true`; with `clientSecret: ""`, `false`.
|
||||
2. POST `/api/bot/settings` with `{ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } }` updates all those fields and echoes the masked view; `saveConfig` called.
|
||||
3. POST with `{ spotify: { backend: "bogus" } }` leaves `backend` unchanged (invalid rejected, not 400 for the whole request — partial-merge semantics like the other fields). Same for `bitrate: 999`.
|
||||
4. POST with `{ spotify: { clientSecret: "newsecret" } }` sets the secret (assert via `hasClientSecret === true` in the echo AND that `config.spotify.clientSecret === "newsecret"`). POST with `{ spotify: { clientSecret: "" } }` does NOT overwrite an existing secret.
|
||||
5. POST `/api/bot/settings` spotify write requires `bot.manage` (a member without it → 403; reuse the existing 403 test pattern).
|
||||
6. An existing settings POST that omits `spotify` still works and does not touch `config.spotify` (no regression).
|
||||
|
||||
- [ ] **Step 1.2: Run the tests — expect failure** (`npx vitest run src/web/api/bot.test.ts`): the `spotify` key is absent from responses.
|
||||
|
||||
- [ ] **Step 1.3: Extend GET `/settings`.** Add the masked spotify view to the response object (both the GET at ~line 36 and the POST echo at ~line 103 — extract a local helper to avoid duplication):
|
||||
```ts
|
||||
// near the top of createBotRouter, after other helpers:
|
||||
const maskedSpotify = () => ({
|
||||
enabled: config.spotify.enabled,
|
||||
backend: config.spotify.backend,
|
||||
clientId: config.spotify.clientId,
|
||||
deviceName: config.spotify.deviceName,
|
||||
bitrate: config.spotify.bitrate,
|
||||
hasClientSecret: config.spotify.clientSecret.length > 0,
|
||||
});
|
||||
```
|
||||
Add `spotify: maskedSpotify(),` to BOTH the GET response and the POST echo object.
|
||||
|
||||
- [ ] **Step 1.4: Handle `spotify` in POST `/settings`.** Add `spotify` to the destructure and a partial-merge block (mirror config.ts validation). Place before `saveConfig(...)`:
|
||||
```ts
|
||||
const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const;
|
||||
const VALID_BITRATES = [96, 160, 320];
|
||||
const sp = req.body?.spotify;
|
||||
if (sp && typeof sp === "object") {
|
||||
const t = config.spotify;
|
||||
if (typeof sp.enabled === "boolean") t.enabled = sp.enabled;
|
||||
if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) {
|
||||
t.backend = sp.backend as SpotifyConfig["backend"];
|
||||
}
|
||||
if (typeof sp.clientId === "string") t.clientId = sp.clientId;
|
||||
// Secret is write-only + set-on-non-empty so a blank field never wipes it.
|
||||
if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) {
|
||||
t.clientSecret = sp.clientSecret;
|
||||
}
|
||||
if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) {
|
||||
t.deviceName = sp.deviceName.trim();
|
||||
}
|
||||
if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) {
|
||||
t.bitrate = sp.bitrate;
|
||||
}
|
||||
}
|
||||
```
|
||||
Add the type import if not present: `import type { SpotifyConfig } from "../../data/config.js";`.
|
||||
|
||||
- [ ] **Step 1.5: Run tests — expect pass** (`npx vitest run src/web/api/bot.test.ts`). Then `npx tsc --noEmit` clean.
|
||||
|
||||
- [ ] **Step 1.6: Commit.**
|
||||
```bash
|
||||
git add src/web/api/bot.ts src/web/api/bot.test.ts
|
||||
git commit -m "feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `/api/spotify/status` reports the RESOLVED backend + binary availability (D11)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/spotify/backend-select.ts` (pure resolver) + `src/music/spotify/backend-select.test.ts`
|
||||
- Modify: `src/music/spotify/controller.ts` (delegate `chooseBackend` to the resolver)
|
||||
- Modify: `src/web/api/spotify.ts` (`/status` shape + `getBackendInfo` type)
|
||||
- Modify: `src/web/server.ts` (`getBackendInfo` computes the resolved kind)
|
||||
- Modify: `src/web/api/spotify.test.ts` (update the `/status` shape assertion)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// backend-select.ts
|
||||
export type SpotifyBackendKind = "go-librespot" | "librespot";
|
||||
export function resolveSpotifyBackendKind(
|
||||
backend: "auto" | "go-librespot" | "librespot",
|
||||
goPresent: boolean,
|
||||
rustPresent: boolean,
|
||||
): SpotifyBackendKind | null;
|
||||
// spotify.ts getBackendInfo now returns:
|
||||
{ backend: string; deviceName: string; binaryAvailable: boolean }
|
||||
// /status response now:
|
||||
{ authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean }
|
||||
```
|
||||
- Note: `SpotifyBackendKind` currently lives in `controller.ts`. Move the canonical definition to `backend-select.ts` and re-export it from `controller.ts` (`export type { SpotifyBackendKind } from "./backend-select.js";`) so existing importers are unaffected.
|
||||
|
||||
- [ ] **Step 2.1: Write failing resolver tests** `src/music/spotify/backend-select.test.ts` — the same 8-case matrix S3.5 used, but against the pure function:
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSpotifyBackendKind as pick } from "./backend-select.js";
|
||||
describe("resolveSpotifyBackendKind", () => {
|
||||
it("auto: go present -> go-librespot", () => expect(pick("auto", true, true)).toBe("go-librespot"));
|
||||
it("auto: go absent, rust present -> librespot", () => expect(pick("auto", false, true)).toBe("librespot"));
|
||||
it("auto: neither -> null", () => expect(pick("auto", false, false)).toBeNull());
|
||||
it("go-librespot: present -> go-librespot", () => expect(pick("go-librespot", true, true)).toBe("go-librespot"));
|
||||
it("go-librespot: absent -> null even if rust present", () => expect(pick("go-librespot", false, true)).toBeNull());
|
||||
it("librespot: present -> librespot", () => expect(pick("librespot", true, true)).toBe("librespot"));
|
||||
it("librespot: absent -> null even if go present", () => expect(pick("librespot", true, false)).toBeNull());
|
||||
it("auto default fallthrough matches auto", () => expect(pick("auto", true, false)).toBe("go-librespot"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Run — expect failure** (module missing).
|
||||
|
||||
- [ ] **Step 2.3: Create the resolver** `src/music/spotify/backend-select.ts`:
|
||||
```ts
|
||||
/** Which concrete backend runs for a given config + host binary availability. */
|
||||
export type SpotifyBackendKind = "go-librespot" | "librespot";
|
||||
|
||||
/**
|
||||
* Pure backend selection shared by SpotifyController.chooseBackend() (per-bot)
|
||||
* and the web /status endpoint (process-wide). Booleans in, no IO — the caller
|
||||
* supplies platform+binary presence.
|
||||
*/
|
||||
export function resolveSpotifyBackendKind(
|
||||
backend: "auto" | "go-librespot" | "librespot",
|
||||
goPresent: boolean,
|
||||
rustPresent: boolean,
|
||||
): SpotifyBackendKind | null {
|
||||
switch (backend) {
|
||||
case "go-librespot":
|
||||
return goPresent ? "go-librespot" : null;
|
||||
case "librespot":
|
||||
return rustPresent ? "librespot" : null;
|
||||
case "auto":
|
||||
default:
|
||||
if (goPresent) return "go-librespot";
|
||||
if (rustPresent) return "librespot";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Run resolver tests — expect pass.**
|
||||
|
||||
- [ ] **Step 2.5: Delegate from the controller.** In `controller.ts`, import the type+resolver **with a local binding** (a bare `export … from` re-export does NOT create a local name, and `SpotifyBackendKind` is still referenced locally at `chooseBackend()`'s return type and `buildBackend(kind: SpotifyBackendKind)` → would fail TS2304). Use:
|
||||
```ts
|
||||
import { resolveSpotifyBackendKind, type SpotifyBackendKind } from "./backend-select.js";
|
||||
export type { SpotifyBackendKind }; // keep the name exported for existing importers
|
||||
// ...
|
||||
chooseBackend(): SpotifyBackendKind | null {
|
||||
return resolveSpotifyBackendKind(this.config.backend, this.goPresent(), this.rustPresent());
|
||||
}
|
||||
```
|
||||
Remove the old inline `switch` body and the standalone `export type SpotifyBackendKind = "go-librespot" | "librespot";` line. Keep `goPresent()/rustPresent()` as-is. Run `npx vitest run src/music/spotify/controller.test.ts` — the S3.5 matrix + auth-gate specs MUST still pass unchanged.
|
||||
|
||||
- [ ] **Step 2.6: Update `/status`** in `src/web/api/spotify.ts` — extend the `getBackendInfo` type and the response:
|
||||
```ts
|
||||
getBackendInfo: () => { backend: string; deviceName: string; binaryAvailable: boolean };
|
||||
// ...
|
||||
router.get("/status", requireNotGuest, (_req, res) => {
|
||||
const info = opts.getBackendInfo();
|
||||
res.json({
|
||||
authorized: oauth.isAuthorized(),
|
||||
backend: info.backend,
|
||||
deviceName: info.deviceName,
|
||||
binaryAvailable: info.binaryAvailable,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2.7: Compute the resolved kind in `server.ts`.** Replace the `getBackendInfo` closure (currently returns raw `config.spotify.backend`) with a resolver call using live probes. Add imports `import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";` and `import { isGoLibrespotSupported, findGoLibrespot, isRustLibrespotSupported, findLibrespot } from "../music/spotify/binary.js";` and `import { existsSync } from "node:fs";` (verify existsSync isn't already imported):
|
||||
```ts
|
||||
getBackendInfo: () => {
|
||||
const goPresent = isGoLibrespotSupported() && existsSync(findGoLibrespot());
|
||||
const rustPresent = isRustLibrespotSupported() && existsSync(findLibrespot());
|
||||
const resolved = resolveSpotifyBackendKind(options.config.spotify.backend, goPresent, rustPresent);
|
||||
return {
|
||||
backend: resolved ?? "none",
|
||||
deviceName: options.config.spotify.deviceName,
|
||||
binaryAvailable: resolved !== null,
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2.8: Update the `/status` test** in `src/web/api/spotify.test.ts` — the existing `toEqual({ authorized, backend, deviceName })` must become `toEqual({ authorized, backend, deviceName, binaryAvailable })`; extend the fake `getBackendInfo` in that test to return `binaryAvailable`. This is THIS task's contract change; update only the status test.
|
||||
|
||||
- [ ] **Step 2.9: Verify.** `npx vitest run src/music/spotify/backend-select.test.ts src/music/spotify/controller.test.ts src/web/api/spotify.test.ts` all pass; `npx tsc --noEmit` clean.
|
||||
|
||||
- [ ] **Step 2.10: Commit.**
|
||||
```bash
|
||||
git add src/music/spotify/backend-select.ts src/music/spotify/backend-select.test.ts src/music/spotify/controller.ts src/web/api/spotify.ts src/web/server.ts src/web/api/spotify.test.ts
|
||||
git commit -m "feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: OAuth robustness — in-flight refresh cache + verifier TTL/cap (D9)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music/spotify/spotify-oauth.ts`
|
||||
- Test: `src/music/spotify/spotify-oauth.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `SpotifyOAuthOptions.deps?: { http?: AxiosInstance }`. Extend deps with an optional clock for testability: `deps?: { http?: AxiosInstance; now?: () => number }`.
|
||||
- Produces: no public API change. Internals: `refreshInFlight: Promise<string|null> | null`; `pendingVerifiers: Map<string, { verifier: string; expiresAt: number }>`.
|
||||
|
||||
- [ ] **Step 3.1: Write failing tests.** Add to `src/music/spotify/spotify-oauth.test.ts`:
|
||||
1. **Concurrent refresh collapses to one POST.** Build with a fake `http` whose `post("/api/token")` returns a promise you resolve manually (a `Deferred`) or counts calls, and a MUTABLE fake store (`save()` persists, `load()` returns the last saved value) seeded with an expired token. Fire two `getAccessToken()` calls before the POST resolves; assert `http.post` called exactly ONCE and both awaited results equal the new access token.
|
||||
2. **In-flight clears after settle.** Using the same mutable store + a mutable `now` (`let t=…; now=()=>t`), after test 1 resolves, advance `t` past the newly-saved `expiresAt` and call `getAccessToken()` again → a NEW POST fires (count → 2). (Requires `toTokens` on `this.now()` per Step 3.3.)
|
||||
3. **Verifier TTL.** With injected mutable `now`, `buildAuthorizeUrl()` at t=0 (capture its `state`), advance `now` to TTL+1, then `handleCallback(code, state)` → returns false (expired) and the entry is gone.
|
||||
4. **Verifier cap.** Call `buildAuthorizeUrl()` `VERIFIER_MAX + 1` times (capturing the FIRST `state`); assert **behaviorally** that `handleCallback(code, <first state>)` now returns false (evicted as oldest). Prefer this behavioral assertion over inspecting the private `pendingVerifiers` map (no `as any` cast). Use the injected `now` for all timing.
|
||||
|
||||
- [ ] **Step 3.2: Run — expect failure.**
|
||||
|
||||
- [ ] **Step 3.3: Add the clock + in-flight refresh.** In the constructor: `this.now = o.deps?.now ?? (() => Date.now());` (add `private now: () => number;`). Rewrite `getAccessToken()` + add the in-flight field:
|
||||
```ts
|
||||
private refreshInFlight: Promise<string | null> | null = null;
|
||||
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
if (!this.clientId) return null;
|
||||
const tokens = this.store.load();
|
||||
if (!tokens?.refreshToken) return null;
|
||||
if (tokens.accessToken && this.now() < tokens.expiresAt) return tokens.accessToken;
|
||||
// Collapse concurrent refreshes: rotation makes a second in-flight refresh
|
||||
// use a refresh token the first one already invalidated.
|
||||
if (this.refreshInFlight) return this.refreshInFlight;
|
||||
this.refreshInFlight = this.refresh(tokens).finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
return this.refreshInFlight;
|
||||
}
|
||||
```
|
||||
Replace the `Date.now()` in `getAccessToken` (spotify-oauth.ts:188) with `this.now()`. **Also change `toTokens` (spotify-oauth.ts:~221) to compute `expiresAt` from `this.now()` instead of `Date.now()`** — this is REQUIRED for testability: if `toTokens` kept real `Date.now()` while `getAccessToken` used an injected fixed clock, a freshly-refreshed token's `expiresAt` would sit far in the injected past/future and the "subsequent call → new POST" test would be non-deterministic. With both on `this.now()`, the test drives a mutable `now` (e.g. `let t = 0; const now = () => t;`) and advances it past the new `expiresAt` to force the second refresh.
|
||||
|
||||
- [ ] **Step 3.4: Add verifier TTL + cap.** Change the map type and the two touch points:
|
||||
```ts
|
||||
private pendingVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
|
||||
private static readonly VERIFIER_TTL_MS = 10 * 60 * 1000;
|
||||
private static readonly VERIFIER_MAX = 32;
|
||||
|
||||
private evictStaleVerifiers(): void {
|
||||
const t = this.now();
|
||||
for (const [state, e] of this.pendingVerifiers) {
|
||||
if (e.expiresAt < t) this.pendingVerifiers.delete(state);
|
||||
}
|
||||
// Bound memory even if all are unexpired: drop oldest (insertion order).
|
||||
while (this.pendingVerifiers.size >= SpotifyOAuth.VERIFIER_MAX) {
|
||||
const oldest = this.pendingVerifiers.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.pendingVerifiers.delete(oldest);
|
||||
}
|
||||
}
|
||||
```
|
||||
In `buildAuthorizeUrl()`: call `this.evictStaleVerifiers();` before the set, then `this.pendingVerifiers.set(state, { verifier, expiresAt: this.now() + SpotifyOAuth.VERIFIER_TTL_MS });`.
|
||||
In `handleCallback()`: replace the `get`:
|
||||
```ts
|
||||
const entry = this.pendingVerifiers.get(state);
|
||||
if (!entry || entry.expiresAt < this.now()) {
|
||||
this.pendingVerifiers.delete(state);
|
||||
return false;
|
||||
}
|
||||
const verifier = entry.verifier;
|
||||
```
|
||||
(Keep the existing `finally { this.pendingVerifiers.delete(state); }`.)
|
||||
|
||||
- [ ] **Step 3.5: Run tests — expect pass.** Then `npx vitest run src/music/spotify/spotify-oauth.test.ts` (all, incl. prior S3.2 tests) + `npx tsc --noEmit` clean.
|
||||
|
||||
- [ ] **Step 3.6: Commit.**
|
||||
```bash
|
||||
git add src/music/spotify/spotify-oauth.ts src/music/spotify/spotify-oauth.test.ts
|
||||
git commit -m "fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Settings "Connect Spotify" card (spec §8)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/composables/useSpotifySettings.ts` (pure, testable logic) + `web/src/composables/useSpotifySettings.test.ts`
|
||||
- Modify: `web/src/views/Settings.vue` (add the card + wiring)
|
||||
|
||||
**Interfaces (composable — the unit-tested surface):**
|
||||
```ts
|
||||
export interface SpotifyConfigForm {
|
||||
enabled: boolean;
|
||||
backend: "auto" | "go-librespot" | "librespot";
|
||||
clientId: string;
|
||||
clientSecret: string; // blank means "unchanged"
|
||||
deviceName: string;
|
||||
bitrate: number;
|
||||
}
|
||||
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
|
||||
export const SPOTIFY_DISCLAIMER: string; // Chinese risk copy
|
||||
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> }; // omits clientSecret when blank
|
||||
export function parseSpotifyRedirect(search: string): "success" | "error" | null; // from ?spotify=...
|
||||
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" };
|
||||
```
|
||||
|
||||
- [ ] **Step 4.1: Write failing composable tests** `web/src/composables/useSpotifySettings.test.ts` (root vitest picks it up; import from `./useSpotifySettings.js` per the repo's ESM `.js` convention):
|
||||
- `buildSpotifyPayload` includes `clientSecret` only when non-blank; always includes enabled/backend/clientId/deviceName/bitrate under a `spotify` key.
|
||||
- `parseSpotifyRedirect("?spotify=success")==="success"`, `"?spotify=error"==="error"`, `"?x=1"===null`.
|
||||
- `statusSummary(null, false)` → tone `"off"`; `statusSummary({authorized:false,binaryAvailable:false,...}, true)` → tone `"warn"`; `statusSummary({authorized:true,binaryAvailable:true,...}, true)` → tone `"ok"`.
|
||||
|
||||
- [ ] **Step 4.2: Run — expect failure** (module missing).
|
||||
|
||||
- [ ] **Step 4.3: Implement the composable** `web/src/composables/useSpotifySettings.ts`:
|
||||
```ts
|
||||
export interface SpotifyConfigForm { enabled: boolean; backend: "auto" | "go-librespot" | "librespot"; clientId: string; clientSecret: string; deviceName: string; bitrate: number; }
|
||||
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
|
||||
|
||||
// 实验性 · 灰色地带 · 需要 Premium · 使用你自己的开发者应用凭据
|
||||
export const SPOTIFY_DISCLAIMER =
|
||||
"实验性功能:通过 librespot 播放 Spotify 需要 Spotify Premium 账号,并使用你自己注册的 Spotify 开发者应用凭据。" +
|
||||
"该方式处于 Spotify 服务条款的灰色地带,风险自负;默认关闭,不会内置任何共享凭据。";
|
||||
|
||||
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> } {
|
||||
const spotify: Record<string, unknown> = {
|
||||
enabled: f.enabled,
|
||||
backend: f.backend,
|
||||
clientId: f.clientId,
|
||||
deviceName: f.deviceName,
|
||||
bitrate: f.bitrate,
|
||||
};
|
||||
if (f.clientSecret && f.clientSecret.length > 0) spotify.clientSecret = f.clientSecret;
|
||||
return { spotify };
|
||||
}
|
||||
|
||||
export function parseSpotifyRedirect(search: string): "success" | "error" | null {
|
||||
const v = new URLSearchParams(search).get("spotify");
|
||||
return v === "success" || v === "error" ? v : null;
|
||||
}
|
||||
|
||||
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" } {
|
||||
if (!enabled) return { label: "已关闭", tone: "off" };
|
||||
if (!s) return { label: "未知", tone: "warn" };
|
||||
if (!s.binaryAvailable) return { label: "未检测到 librespot 可执行文件", tone: "warn" };
|
||||
if (!s.authorized) return { label: "未授权(点击“连接 Spotify”登录)", tone: "warn" };
|
||||
return { label: `已就绪 · 后端 ${s.backend}`, tone: "ok" };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4: Run composable tests — expect pass.**
|
||||
|
||||
- [ ] **Step 4.5: Add the card to `Settings.vue`.** Insert a new `.account-card`-style section in the settings surface, gated `v-if="can('platform.auth')"` (mirror the platform-login section). Follow the EXISTING patterns in this file:
|
||||
- typed input + Save → mirror the idle-timeout input/saver (`Settings.vue` idle-timeout block + `saveIdleTimeout()` → `POST /api/bot/settings`);
|
||||
- select group → mirror the quality button-group for `backend` and `bitrate`;
|
||||
- toggle → mirror `localAudioEnabled` for `enabled`.
|
||||
**Fields to render (all six):** the `enabled` toggle, `backend` group, `bitrate` group, a `clientId` text input, a `deviceName` text input, and a **Client Secret** field — a write-only password input (`type="password"`, `autocomplete="off"`), left BLANK on load with a "已设置 / 未设置" hint from `hasClientSecret`; a blank secret on Save means "unchanged" (never wipes). The Secret is §8-mandated — do not omit it.
|
||||
Wiring (use `axios`, matching the file's other calls):
|
||||
- **Load** on mount: `GET /api/bot/settings` → populate the form from `res.data.spotify` (leave `clientSecret` blank; show “已设置/未设置” from `hasClientSecret`); `GET /api/spotify/status` → status indicator via `statusSummary`.
|
||||
- **Save**: `POST /api/bot/settings` with `buildSpotifyPayload(form)`; on success re-load status.
|
||||
- **Connect**: `const { data } = await axios.get('/api/spotify/login'); window.location.href = data.url;` (guard errors → show message; a 403 means missing `platform.auth`).
|
||||
- **Redirect handling**: on mount, `parseSpotifyRedirect(window.location.search)`; if `success`/`error`, show a toast/message and strip the param (e.g. `history.replaceState`). Reuse the file's existing notification/toast mechanism if present; otherwise a simple reactive message line.
|
||||
- **Disclaimer**: render `SPOTIFY_DISCLAIMER` prominently in the card.
|
||||
Keep the `<script setup>` logic thin — delegate payload/summary/redirect parsing to the composable (already tested). Do not add a `.vue` test (no harness).
|
||||
- **Keep the two "spotify auth" concepts distinct:** the card's status comes ONLY from `/api/spotify/status` (playback OAuth `authorized`). Do NOT wire it to the player store's `authStatus.spotify`, which reflects `/api/auth/status?platform=spotify` (metadata Web-API `loggedIn`) — a different thing. Leave the player store untouched.
|
||||
- **`vue-tsc` typing:** annotate the form as `reactive<SpotifyConfigForm>({...})` and the status as `ref<SpotifyStatus | null>(null)`; otherwise a button-group assignment (`form.backend = 'librespot'`) widens `backend` to `string` and the `null` init breaks the union passed into `buildSpotifyPayload`/`statusSummary` under `vue-tsc --noEmit`.
|
||||
- **Hard order dependency:** `binaryAvailable` on `/api/spotify/status` exists only after Task 2 — execute this task AFTER Task 2.
|
||||
|
||||
- [ ] **Step 4.6: Build gate.** `cd web && npm run build` → `vue-tsc --noEmit` clean + vite build succeeds. Then from root `npx vitest run web/src/composables/useSpotifySettings.test.ts` green.
|
||||
|
||||
- [ ] **Step 4.7: Commit.**
|
||||
```bash
|
||||
git add web/src/composables/useSpotifySettings.ts web/src/composables/useSpotifySettings.test.ts web/src/views/Settings.vue
|
||||
git commit -m "feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]"
|
||||
```
|
||||
|
||||
**Notes:** NOT e2e-verifiable (needs a real account/binary). Verified = composable unit tests + `vue-tsc` typecheck + build. The card only exposes config + a login trigger; it never displays the stored `clientSecret` (GET returns `hasClientSecret`, not the value).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: README Spotify section (Chinese) — spec §9/§11/§12
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md` (add a `## Spotify 音源(实验性)` section; add "Spotify" to the feature bullet if appropriate)
|
||||
|
||||
**Content (required — write real prose, not placeholders):**
|
||||
- [ ] **Step 5.1:** Add a top-level section `## Spotify 音源(实验性)` covering, in Chinese:
|
||||
1. **醒目警告框:** 实验性;需要 **Spotify Premium**;使用**你自己注册的 Spotify 开发者应用**(不内置任何共享凭据);处于 Spotify 服务条款灰色地带,风险自负;**默认关闭**。
|
||||
2. **工作原理(简述):** 通过 librespot(Rust)/ go-librespot(Linux)作为独立进程解码 → PCM → ffmpeg 重采样到 48k → 走现有 Opus 发送管线。元数据来自 Spotify Web API。
|
||||
3. **平台矩阵:** Windows → `librespot`(Rust);Linux/Docker → `go-librespot`(可回退 `librespot`);`auto` 自动选择(表格)。
|
||||
4. **获取二进制:** Rust librespot 无预编译包 → `cargo install librespot` 或 scoop/choco,或将可执行文件放入项目 `bin/`(`bin/librespot.exe` / `bin/librespot`)。go-librespot 仅提供 Linux 资产(`github.com/devgianlu/go-librespot/releases`),放入 `bin/go-librespot` 或加入 PATH。
|
||||
5. **注册开发者应用 + 回调:** 在 Spotify Developer Dashboard 建应用,取 Client ID,回调地址填 `http://127.0.0.1:<webPort>/api/spotify/callback`(与设置里的 Web 端口一致);PKCE 不需要 Client Secret。
|
||||
6. **启用步骤:** 设置页「连接 Spotify」卡片 → 填 Client ID、选后端、开启开关 → 保存 → 点「连接 Spotify」完成 OAuth 授权。
|
||||
7. **许可与来源:** go-librespot 为 **GPL-3.0**,作为独立子进程调用(mere aggregation,不影响本项目许可);附上其源码地址与许可说明(source offer)。
|
||||
8. **故障排查:** 「未检测到 librespot」→ 检查 `bin/` 或 PATH;「未授权」→ 完成 OAuth;Windows 不支持 go-librespot(FIFO 仅限 POSIX)。
|
||||
- [ ] **Step 5.2:** Sanity-check the doc renders (headings consistent with existing `##` style; links valid). No code test.
|
||||
- [ ] **Step 5.3: Commit.**
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rust Connect command retry/backoff (recovery/watchdog — spec §4.3/§13)
|
||||
|
||||
**Rationale:** Spec §4.3 mandates "a watchdog + retry/backoff for device-visibility latency and command 202/404 flakiness"; §13 lists Rust Connect as the **top risk** (mitigation: retry/backoff + degrade-to-skipped). Stage 3 already landed the device-visibility watchdog (`waitForDevice()` bounded poll in `rust-librespot.ts`) and per-track device-absence → skip (`findDeviceByName`→null → `playTrack` false → BotInstance skips). The remaining, un-built piece is transient-failure retry/backoff on the Connect **mutating commands** — this task adds it while preserving C3.6 (never reject up the queue path; swallow on exhaustion).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/music/spotify/connect-api.ts` (retry/backoff around the mutating PUTs; optional injected `sleep`/`logger`)
|
||||
- Modify: `src/music/spotify/controller.ts` (pass `this.logger` into the default `SpotifyConnectApi`)
|
||||
- Test: `src/music/spotify/connect-api.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `constructor(getToken: () => Promise<string|null>, deps?: { http?: AxiosInstance })`. **Extend deps** to `{ http?: AxiosInstance; sleep?: (ms: number) => Promise<void>; logger?: import("pino").Logger }` (all optional → source-compatible).
|
||||
- No public method signature change: `transfer/play/pause/resume/seek` stay `Promise<void>` and still swallow on final failure.
|
||||
|
||||
- [ ] **Step 6.1: Write failing tests** in `src/music/spotify/connect-api.test.ts` (reuse the existing `getToken`/`http` injection pattern; inject a no-op `sleep: async () => {}` so no real timers run):
|
||||
1. `play()` retries a transient 404 then succeeds: `http.put` rejects once with `{ response: { status: 404 } }` then resolves → assert `http.put` called TWICE, no throw.
|
||||
2. `play()` exhausts on persistent 500: `http.put` always rejects `{ response: { status: 500 } }` → assert exactly `MAX_ATTEMPTS` calls, no throw (swallowed), and `logger.warn` called once (logger provided).
|
||||
3. Non-transient (403) is NOT retried: reject `{ response: { status: 403 } }` → exactly ONE call, no throw.
|
||||
4. `429` honors a **capped** `Retry-After`: reject once `{ response: { status: 429, headers: { "retry-after": "1" } } }` then resolve → `sleep` called with a bounded delay (≤ the cap) and 2 calls total.
|
||||
5. `transfer()` uses the same retry path (one representative non-`play` mutator) — 404-then-success → 2 calls.
|
||||
|
||||
- [ ] **Step 6.2: Confirm Red** (`npx vitest run src/music/spotify/connect-api.test.ts`).
|
||||
|
||||
- [ ] **Step 6.3: Implement retry/backoff.** Add module constants + a private helper and route each mutating PUT through it:
|
||||
```ts
|
||||
const TRANSIENT = new Set([404, 429, 500, 502, 503]);
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BASE_DELAY_MS = 150;
|
||||
const MAX_DELAY_MS = 2_000;
|
||||
// ...
|
||||
private async mutateWithRetry(put: () => Promise<unknown>): Promise<void> {
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await put();
|
||||
return;
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (!TRANSIENT.has(status) || attempt === MAX_ATTEMPTS) {
|
||||
// C3.6: never reject up the queue path — swallow, but surface once.
|
||||
this.logger?.warn({ status }, "Spotify Connect command failed (exhausted/non-retryable)");
|
||||
return;
|
||||
}
|
||||
let delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
||||
if (status === 429) {
|
||||
const ra = Number(err?.response?.headers?.["retry-after"]);
|
||||
if (Number.isFinite(ra) && ra > 0) delay = Math.min(ra * 1000, MAX_DELAY_MS);
|
||||
}
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
In the constructor: `this.sleep = deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));` and `this.logger = deps?.logger;`. Rewrite `transfer/play/pause/resume/seek` so their body becomes: guard `authHeaders()` as today (unauth → `return;`, no retry), then `await this.mutateWithRetry(() => this.http.put(<url>, <body>, { headers, ... }));`. Keep the exact URLs/bodies/params from the current methods.
|
||||
|
||||
- [ ] **Step 6.4: Thread the logger from the controller.** In `controller.ts` where the default connect is built:
|
||||
```ts
|
||||
this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken(), { logger: this.logger });
|
||||
```
|
||||
(`Logger` is already imported in controller.ts.) Injected-connect tests are unaffected.
|
||||
|
||||
- [ ] **Step 6.5: Update any conflicting existing connect test.** The S3.3 (C3.6) tests assert mutating calls swallow errors. If any asserts a single `http.put` call on a *transient*-status rejection, it now expects `MAX_ATTEMPTS` — update ONLY that count (this task's contract change). Do NOT weaken the swallow/no-throw guarantees or the non-transient single-call behavior.
|
||||
|
||||
- [ ] **Step 6.6: Verify.** `npx vitest run src/music/spotify/connect-api.test.ts src/music/spotify/controller.test.ts` all pass; `npx tsc --noEmit` clean.
|
||||
|
||||
- [ ] **Step 6.7: Commit.**
|
||||
```bash
|
||||
git add src/music/spotify/connect-api.ts src/music/spotify/connect-api.test.ts src/music/spotify/controller.ts
|
||||
git commit -m "feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]"
|
||||
```
|
||||
|
||||
**Notes:** Bounded retry adds latency only on the (off-audio-path) control commands and only on transient failure; the injected `sleep` makes tests instant. Not e2e-verifiable (no real account). The `202`-accepted-but-not-effective case from §4.3 is NOT handled here (it needs post-command state verification, which the existing poll-based track-end already tolerates) — noted as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Stage 4 verification + whole-branch final review + finish
|
||||
|
||||
**Files:** none (verification + review + branch finish).
|
||||
|
||||
- [ ] **Step 7.1: Full verification.** `npx vitest run --no-file-parallelism` (all green), `npx tsc --noEmit` (clean), `cd web && npm run build` (clean). Record counts.
|
||||
- [ ] **Step 7.2: Whole-branch adversarial review.** Review the ENTIRE `feat/spotify-audio` branch (`git merge-base main HEAD`..HEAD) — Stages 2+3+4 — with a fan-out of finders across dimensions (lifecycle/teardown correctness, OAuth/token security + no-secret-leak, backend selection + gating, async races, error handling, test hygiene) and adversarial verification of each finding. Dispatch ONE fix subagent with the consolidated Critical/Important findings; roll up Minors.
|
||||
- [ ] **Step 7.3: Address findings**, re-verify, then use superpowers:finishing-a-development-branch to complete the branch (tests green → present options → execute choice).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (author)
|
||||
|
||||
- **Spec coverage:** §8 Settings card → Task 4; §9 binaries/GPL note + §11 README → Task 5; §12 "docs, README" → Task 5; §12/§4.3/§13 "recovery/watchdog" (Connect retry/backoff) → Task 6 (device-visibility watchdog + per-track skip already landed in Stage 3); D9 → Task 3; D11 → Task 2; config editability (prereq for the card) → Task 1.
|
||||
- **Deferred (documented, NOT built this stage), with rationale:** runtime binary auto-download (spec §9 calls it "optional"; README documents manual install + Docker instead — avoids a tar.gz/checksum/platform-detection downloader on the critical path); the §4.3 `202`-accepted-but-not-effective verification (needs post-command playback-state confirmation the poll-based track-end already tolerates); D10's separate play-not-reflected diagnostic beyond the retry-exhaustion `logger.warn` Task 6 adds. These are listed in the ledger as follow-ups.
|
||||
- **Placeholder scan:** backend tasks (1–3) carry complete code; frontend/docs (4–5) carry the tested composable in full + explicit wiring contracts + named existing patterns to mirror (Settings.vue has no component-test harness, so exact `.vue` template text is intentionally pattern-referenced, not dictated line-by-line).
|
||||
- **Type consistency:** `SpotifyBackendKind` canonicalized in `backend-select.ts`, re-exported from `controller.ts`; `getBackendInfo` return type updated in both `spotify.ts` and its `server.ts` supplier and the `/status` test; `buildSpotifyPayload`/`SpotifyStatus` shapes match Task 1's masked view (`hasClientSecret`) and Task 2's `/status` (`binaryAvailable`).
|
||||
@@ -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,124 @@
|
||||
# Bot Profile Manager — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
|
||||
|
||||
## Features
|
||||
|
||||
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|
||||
|---|---------|---------------|-----------------|---------------|---------------|
|
||||
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
|
||||
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
|
||||
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
resolveAndPlay() success / stop / clear / playNext exhausted
|
||||
↓
|
||||
BotInstance → BotProfileManager.onSongChange(song | null)
|
||||
├─ updateAvatar(coverUrl | null) [fire-and-forget]
|
||||
├─ updateDescription(song | null)
|
||||
├─ updateNickname(song | null)
|
||||
├─ updateAwayStatus(song | null)
|
||||
├─ updateChannelDescription(song | null)
|
||||
└─ sendNowPlayingMessage(song) [only when song != null]
|
||||
|
||||
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
|
||||
BotInstance.disconnect() → (no action needed, server cleans up)
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
|
||||
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
|
||||
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
|
||||
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
|
||||
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
|
||||
|
||||
## TS3Client Layer Extensions
|
||||
|
||||
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
|
||||
|
||||
```typescript
|
||||
execCommand(cmd: string): Promise<void>
|
||||
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
|
||||
fileTransferInitUpload(channelID: bigint, path: string, password: string,
|
||||
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
|
||||
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
|
||||
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
|
||||
```
|
||||
|
||||
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
|
||||
|
||||
## BotProfileManager Detail
|
||||
|
||||
```typescript
|
||||
interface ProfileConfig {
|
||||
avatarEnabled: boolean; // default true
|
||||
descriptionEnabled: boolean; // default true
|
||||
nicknameEnabled: boolean; // default true
|
||||
awayStatusEnabled: boolean; // default true
|
||||
channelDescEnabled: boolean; // default true
|
||||
nowPlayingMsgEnabled: boolean; // default true
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Handling
|
||||
|
||||
- Each feature has an independent `permDenied: boolean` flag.
|
||||
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
|
||||
- On `onConnect()` → reset all flags (new connection may have different permissions).
|
||||
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
|
||||
|
||||
### Nickname Truncation
|
||||
|
||||
- Format: `♪ {songInfo} | {defaultNickname}`
|
||||
- TS3 max nickname: 30 characters
|
||||
- If total > 30: truncate songInfo, keep defaultNickname
|
||||
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
|
||||
|
||||
### Avatar Upload Flow (TS3)
|
||||
|
||||
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
|
||||
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
|
||||
3. `uploadFileData(host, info, Readable.from(buffer))`
|
||||
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
|
||||
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
|
||||
|
||||
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
|
||||
|
||||
### Fire-and-Forget
|
||||
|
||||
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
|
||||
|
||||
## Database Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
|
||||
```
|
||||
|
||||
## Web API
|
||||
|
||||
```
|
||||
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
|
||||
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- All profile operations are async, never block playback
|
||||
- Uses existing `axios` dependency for image download
|
||||
- MD5 via Node.js built-in `crypto`
|
||||
- No new npm dependencies required
|
||||
@@ -0,0 +1,125 @@
|
||||
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
|
||||
|
||||
Date: 2026-04-27
|
||||
|
||||
## Overview
|
||||
|
||||
Three features for the TeamSpeak Music Bot:
|
||||
1. New `!artist <name>` command — loop playback filtered by artist
|
||||
2. Fuzzy playlist name search in existing `!playlist` command
|
||||
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: `!artist` Command
|
||||
|
||||
### Behavior
|
||||
|
||||
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
|
||||
2. Call `provider.search(歌手名, 50)` to get up to 50 results
|
||||
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
|
||||
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
|
||||
5. Clear current queue, add filtered songs, set mode to `Loop`
|
||||
6. Play first song via `resolveAndPlay`
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
|
||||
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
|
||||
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
|
||||
- `src/bot/instance.ts`: New `cmdArtist()` method
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Playlist Fuzzy Search
|
||||
|
||||
### Behavior
|
||||
|
||||
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
|
||||
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
|
||||
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
|
||||
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
|
||||
5. Merge results: public search results first (sorted by API relevance), then user matches
|
||||
6. Take the first playlist, load its songs, play
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
|
||||
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
|
||||
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
|
||||
- `src/bot/commands.ts`: Update help text
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: FM Bug Fix
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
|
||||
|
||||
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
|
||||
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
|
||||
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
|
||||
|
||||
The most likely scenario for "no audio but status shows playing + song names changing":
|
||||
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
|
||||
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
|
||||
3. ffmpeg exits quickly (clean exit or error)
|
||||
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
|
||||
5. `playNext()` advances to the next song
|
||||
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
|
||||
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
|
||||
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
|
||||
|
||||
### Fix Strategy
|
||||
|
||||
**Fix 1 — FM auto-refill (primary fix):**
|
||||
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
|
||||
- Add a `refillFm()` method that fetches more FM songs and appends to queue
|
||||
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
|
||||
- Track FM state with a boolean flag `isFmMode` on the instance
|
||||
|
||||
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
|
||||
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
|
||||
- This prevents transient URL failures from accumulating toward the hard limit
|
||||
|
||||
**Fix 3 — FM refill before queue exhaustion:**
|
||||
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
|
||||
- If so, fire an async refill (don't block playback)
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
|
||||
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **FM bug fix** first — it's a bug fix affecting current users
|
||||
2. **Playlist fuzzy search** — small change, quick win
|
||||
3. **Artist loop** — new feature, depends on queue/player being stable
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
|
||||
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
|
||||
- Artist: Test with known artist names, test edge case (no results), test with platform flags
|
||||
@@ -0,0 +1,189 @@
|
||||
# Multi-Source Tabs for Recommend / User Playlists / Daily Songs
|
||||
|
||||
**Date:** 2026-05-06
|
||||
**Status:** Spec — pending implementation
|
||||
|
||||
## Problem
|
||||
|
||||
Home 和 Library 页面的"推荐歌单 / 每日推荐 / 我的歌单"这三类内容当前硬编码只走网易云。当用户同时登录了网易云和 QQ 音乐时,无法在 Web UI 上看到 QQ 侧的对应内容、也无法切换查看。
|
||||
|
||||
## Goal
|
||||
|
||||
在以下 4 个 section 上提供"网易云 / QQ"来源切换 tab,桌面端和移动端均可用:
|
||||
|
||||
- `Home.vue` — 推荐歌单
|
||||
- `Home.vue` — 每日推荐
|
||||
- `Home.vue` — 我的歌单
|
||||
- `Library.vue` — 我的歌单
|
||||
|
||||
切换为纯前端动作(数据已预先 fetch),无加载闪烁。各 section 的选择独立持久化。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 私人 FM(QQ 无对应概念)
|
||||
- B 站热门(独立第三来源,不属于网易/QQ 切换语义)
|
||||
- 最近播放(bot 维度的播放历史,与音乐源无关)
|
||||
- Library 现有的"我的收藏"段落 —— 当前调用的 `/api/music/user/liked` 端点不存在,是死代码,本次顺手移除
|
||||
- 登录状态实时同步(用户在 Settings 登录后需手动刷新 Home/Library 才能看到 QQ tab)
|
||||
- Tab 排序、隐藏、拖动等高级配置
|
||||
|
||||
## Non-functional Constraints
|
||||
|
||||
- 桌面端(>768px)和移动端(≤768px)布局均可用,tab 与 section title 同行排布;空间不足时允许 flex-wrap
|
||||
- Tab 触控区域有效高度 ≥36px
|
||||
- 现有 5 分钟 home data cache 行为保留
|
||||
- 不引入新的后端端点(后端已通过 `?platform=` 参数支持多源)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 数据层(`web/src/stores/player.ts`)
|
||||
|
||||
字段从单平台改为按 platform 切分:
|
||||
|
||||
```ts
|
||||
// 前
|
||||
recommendPlaylists: PlaylistItem[]
|
||||
userPlaylists: PlaylistItem[]
|
||||
dailySongs: Song[]
|
||||
|
||||
// 后
|
||||
recommendPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
|
||||
userPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
|
||||
dailySongs: { netease: Song[]; qq: Song[] }
|
||||
|
||||
// 新增
|
||||
authStatus: { netease: boolean; qq: boolean }
|
||||
```
|
||||
|
||||
`fetchHomeData()` 改写:
|
||||
|
||||
1. 并发调用 `/api/auth/status?platform=netease` 与 `?platform=qq`,写入 `authStatus`
|
||||
2. 网易云的三类数据照常 fetch(推荐歌单匿名可访问;每日推荐和我的歌单需登录,未登录时 API 自然返回空或失败,`Promise.allSettled` 已隔离)
|
||||
3. QQ 的三类数据**仅在 QQ 登录时** fetch,未登录则为空数组
|
||||
4. B 站热门保持原样
|
||||
5. 5 分钟缓存 TTL 不变
|
||||
|
||||
### UI 组件
|
||||
|
||||
新增 `web/src/components/SourceTabs.vue`:
|
||||
|
||||
```vue
|
||||
<SourceTabs v-model="activeSource" :sources="availableSources" />
|
||||
```
|
||||
|
||||
Props:
|
||||
- `sources: ('netease' | 'qq')[]` — 由父组件根据 auth 状态过滤后传入
|
||||
- `modelValue: 'netease' | 'qq'` — v-model 绑定
|
||||
|
||||
行为:
|
||||
- `sources.length < 2` 时组件**自身不渲染**(返回空),父组件无需 v-if 包装
|
||||
- 文字标签:`{ netease: '网易云', qq: 'QQ' }`
|
||||
- 视觉:水平排列,激活态用主色(`var(--color-primary)`)下划线 + 加粗,未激活态使用次要文字色
|
||||
- 紧贴 section-title 右侧,使用 `display: inline-flex`,移动端 padding/font-size 缩小
|
||||
|
||||
### 各 section 接入模板
|
||||
|
||||
```vue
|
||||
<section v-if="recommendAvailable.length > 0" class="section">
|
||||
<h2 class="section-title">
|
||||
推荐歌单
|
||||
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
|
||||
</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="pl in store.recommendPlaylists[recommendSource]"
|
||||
:key="pl.id"
|
||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ pl.name }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
每个 section 在 `<script setup>` 维护两个值:
|
||||
|
||||
- `recommendSource: Ref<'netease' | 'qq'>` — 当前选中
|
||||
- `recommendAvailable: ComputedRef<('netease' | 'qq')[]>` — 该 section 在当前登录状态下有哪些 source 可选
|
||||
|
||||
`recommendAvailable` 计算规则:
|
||||
|
||||
| Section | netease 加入条件 | qq 加入条件 |
|
||||
|---|---|---|
|
||||
| Home 推荐歌单 | 总是(公开数据) | `authStatus.qq` |
|
||||
| Home 每日推荐 | `authStatus.netease` | `authStatus.qq` |
|
||||
| Home 我的歌单 | `authStatus.netease` | `authStatus.qq` |
|
||||
| Library 我的歌单 | `authStatus.netease` | `authStatus.qq` |
|
||||
|
||||
#### "我的歌单"展开按钮兼容
|
||||
|
||||
Home 的"我的歌单"现有 `USER_PLAYLIST_LIMIT = 20` 折叠/展开。改造后:
|
||||
|
||||
```ts
|
||||
const visibleUserPlaylists = computed(() => {
|
||||
const all = store.userPlaylists[userSource.value] ?? [];
|
||||
return userPlaylistsExpanded.value ? all : all.slice(0, USER_PLAYLIST_LIMIT);
|
||||
});
|
||||
```
|
||||
|
||||
切换 source 时折叠态保留(不需 reset)。
|
||||
|
||||
### 持久化
|
||||
|
||||
localStorage 键统一为一个 JSON:
|
||||
|
||||
```
|
||||
key: "source-tabs"
|
||||
value: {
|
||||
"home.recommend": "qq",
|
||||
"home.daily": "netease",
|
||||
"home.user": "qq",
|
||||
"library.user": "netease"
|
||||
}
|
||||
```
|
||||
|
||||
读:组件 mount 时一次性读 + 解析。
|
||||
写:在 v-model 的 setter 里 `watch` 一次写回。
|
||||
不存在的键 / 解析失败 / 老用户没这个 key —— 默认值 `"netease"`。
|
||||
|
||||
### 边界与回退
|
||||
|
||||
| 情况 | 行为 |
|
||||
|---|---|
|
||||
| 选的 source 不在 `available` 里(例:选了 QQ,登出后回到页面) | 渲染时 fallback 到 `available[0]`,不修改 localStorage(保留用户偏好,下次登回来仍生效) |
|
||||
| `recommendPlaylists.qq` 为空数组(API 失败 or 无数据) | tab 仍可切,切过去显示空 grid(无错误提示,符合现有"空数据隐藏 section"语义;section 顶层 v-if 检查的是 `available.length > 0`,单 platform 数据为空不影响 section 显隐) |
|
||||
| QQ provider 不支持 `getDailyRecommendSongs`(501) | `Promise.allSettled` 已捕获,`dailySongs.qq` 保持 `[]`,等同上一行 |
|
||||
| 用户在 Settings 登录 QQ 后切回 Home | 不自动 refetch / 不自动出 tab —— 用户需手动刷新页面(保留 5 分钟缓存语义) |
|
||||
| 网易云和 QQ 都没登录 | `available` 为空时 section 隐藏(沿用现有逻辑) |
|
||||
| Library "我的收藏" 段落 | 整段移除(含 template、script 中的 `liked` ref、对应 axios 调用) |
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
新增:
|
||||
- `web/src/components/SourceTabs.vue` — 共享 tab 组件
|
||||
|
||||
修改:
|
||||
- `web/src/stores/player.ts` — 多平台 state、`authStatus`、`fetchHomeData` 重写
|
||||
- `web/src/views/Home.vue` — 三个 section 接入 SourceTabs,对应 ref + computed
|
||||
- `web/src/views/Library.vue` — 我的歌单接入 SourceTabs;移除我的收藏死代码
|
||||
|
||||
不改:
|
||||
- 后端(API 已支持 `?platform=`)
|
||||
- `Search.vue` / `Playlist.vue` / `Settings.vue` 等其他页面
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 手动:在两种登录组合下访问 Home 和 Library,验证 tab 显隐、切换、刷新后持久化
|
||||
- 仅网易登录 → 不显示 tab
|
||||
- 两边登录 → 显示 tab,切换后刷新页面来源不变
|
||||
- QQ 登录 → 网易登出 → 网易 tab 消失,若上次选的是网易则 fallback 到 QQ
|
||||
- 移动端(DevTools 768px 以下):tab 与 section-title 同行不溢出,触控区域可点
|
||||
- TypeScript 类型检查通过:`npx tsc --noEmit` + `npm run build:web`
|
||||
- 单元测试:现有 vitest 套件不应回归(store 改动不破坏其他用法)
|
||||
|
||||
## 风险
|
||||
|
||||
- store 字段类型变更(数组 → 对象)会影响所有读取这三个字段的地方。需 grep 确认没有遗漏的消费者。
|
||||
- localStorage 解析失败的容错必须周全,避免一次脏数据导致整个页面白屏。
|
||||
@@ -0,0 +1,226 @@
|
||||
# History-aware `prev` + "Play Next" Insert
|
||||
|
||||
**Date:** 2026-05-06
|
||||
**Status:** Spec — pending implementation
|
||||
|
||||
## Problem
|
||||
|
||||
Two queue/playback gaps surfaced in real use:
|
||||
|
||||
1. In `PlayMode.Random` and `PlayMode.RandomLoop`, `!prev` does not play the
|
||||
actually-previously-played song. It just walks `currentIndex - 1` in the
|
||||
underlying array — but in random modes `currentIndex` jumps non-sequentially,
|
||||
so the "previous" array slot has no relationship to play history.
|
||||
|
||||
2. `!add` / web "添加到队列" appends to the queue tail. There is no way to
|
||||
say "play this song right after the current one." Users want a "下一首
|
||||
播放" affordance comparable to Spotify "Add to Queue (next up)" or Apple
|
||||
Music "Play Next".
|
||||
|
||||
## Goals
|
||||
|
||||
- `prev` walks back through the actual play history regardless of mode.
|
||||
- A new "Play Next" path inserts a song at `currentIndex + 1`, available
|
||||
via web UI button and TS3 chat command.
|
||||
- Both features are usable on desktop and mobile web.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Forward/redo through prev'd songs (user would need to push next manually,
|
||||
which picks a fresh random in random modes — acceptable simplification).
|
||||
- Reordering songs already in the queue ("move to next" inside Queue.vue).
|
||||
- Persisting play history across bot restarts (in-memory only).
|
||||
|
||||
## Non-functional Constraints
|
||||
|
||||
- History capped at 50 entries to bound memory.
|
||||
- `addNext` must keep `playedIndices` and `history` index references valid
|
||||
after insertion (shift all indices > current by +1).
|
||||
- New Toast UX from the previous round still applies (failures surface).
|
||||
- TypeScript and existing test suite must not regress.
|
||||
|
||||
## Architecture
|
||||
|
||||
### A. History-aware `prev`
|
||||
|
||||
**`src/audio/queue.ts`** — `PlayQueue` gains a back-stack:
|
||||
|
||||
```ts
|
||||
private history: number[] = [];
|
||||
private static readonly HISTORY_LIMIT = 50;
|
||||
|
||||
private pushHistory(idx: number): void {
|
||||
if (idx < 0) return;
|
||||
this.history.push(idx);
|
||||
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
|
||||
this.history.shift();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mutators call `pushHistory(this.currentIndex)` **before** changing `currentIndex`:
|
||||
|
||||
| Method | History action |
|
||||
|---|---|
|
||||
| `play()` | `this.history = []` (fresh playback) |
|
||||
| `playAt(idx)` | `pushHistory(currentIndex)`, then set `currentIndex = idx` |
|
||||
| `next()` | `pushHistory(currentIndex)`, then advance per mode |
|
||||
| `prev()` | **Pop** from history → `currentIndex = popped`. If empty, fall back to existing `currentIndex - 1` (which keeps Sequential's wrap behavior; Random returns null). `prev` itself does NOT push to history. |
|
||||
| `clear()` | `this.history = []` |
|
||||
| `setMode(m)` | `this.history = []` (mode change resets context) |
|
||||
| `remove(idx)` | Drop matching entries from history; shift any entry `> idx` by `-1`. Same logic as the existing `playedIndices` rebuild. |
|
||||
|
||||
**`prev()` rewrite:**
|
||||
|
||||
```ts
|
||||
prev(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
// History-driven path (preferred when we have one)
|
||||
while (this.history.length > 0) {
|
||||
const idx = this.history.pop()!;
|
||||
if (idx >= 0 && idx < this.songs.length) {
|
||||
this.currentIndex = idx;
|
||||
this.playedIndices.add(idx);
|
||||
return this.songs[idx];
|
||||
}
|
||||
// popped index is stale (song removed) — keep popping
|
||||
}
|
||||
// Fallback: old index-based prev
|
||||
const prevIndex = this.currentIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
if (this.mode === PlayMode.Sequential) return null;
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
```
|
||||
|
||||
### B. Play Next (insert after current)
|
||||
|
||||
**`PlayQueue.addNext(song)`:**
|
||||
|
||||
```ts
|
||||
addNext(song: QueuedSong): void {
|
||||
if (this.currentIndex < 0 || this.songs.length === 0) {
|
||||
this.songs.push(song);
|
||||
return;
|
||||
}
|
||||
const insertAt = this.currentIndex + 1;
|
||||
this.songs.splice(insertAt, 0, song);
|
||||
// Shift any tracked index > currentIndex by +1
|
||||
const shifted = new Set<number>();
|
||||
for (const i of this.playedIndices) {
|
||||
shifted.add(i > this.currentIndex ? i + 1 : i);
|
||||
}
|
||||
this.playedIndices = shifted;
|
||||
this.history = this.history.map((i) => (i > this.currentIndex ? i + 1 : i));
|
||||
}
|
||||
```
|
||||
|
||||
**Backend endpoint** — `src/web/api/player.ts`:
|
||||
|
||||
```
|
||||
POST /api/player/:botId/play-next-song
|
||||
body: { song: Song }
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- If queue is empty or `currentIndex < 0`: `queue.addNext(song)` (which falls
|
||||
through to plain push), then `queue.play()`, then `resolveAndPlay`. Same
|
||||
semantics as a successful `/play-song` — message: "正在播放:…"
|
||||
- Otherwise: `queue.addNext(song)`, no resolveAndPlay. Message: "已加入下一首:…"
|
||||
- Returns `{ ok: boolean, message: string }` matching the convention
|
||||
established in the previous round.
|
||||
|
||||
**Bot command** — `src/bot/instance.ts`:
|
||||
|
||||
Register `!playnext <query>` (alias `!pn`):
|
||||
- Mirror of `cmdPlay`'s search step
|
||||
- On match: `queue.addNext(song)`. If no current playback, fall through to
|
||||
`resolveAndPlay`.
|
||||
- Reply with `已加入下一首:<name>` or `正在播放:<name>` accordingly
|
||||
|
||||
**Frontend store action** — `web/src/stores/player.ts`:
|
||||
|
||||
```ts
|
||||
async playNextSong(song: Song) {
|
||||
if (!this.activeBotId) return;
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend SongCard** — `web/src/components/SongCard.vue`:
|
||||
|
||||
Add a third action button between the existing "play" and "add to queue":
|
||||
|
||||
```vue
|
||||
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
|
||||
<Icon icon="mdi:playlist-play" />
|
||||
</button>
|
||||
```
|
||||
|
||||
Add `playNext: []` to `defineEmits`.
|
||||
|
||||
**Caller updates** — `Home.vue`, `Library.vue`, `Search.vue`, `History.vue`,
|
||||
`Playlist.vue`: each `<SongCard>` usage adds
|
||||
`@playNext="store.playNextSong(song)"`.
|
||||
|
||||
**Queue.vue** is intentionally **not** updated — clicking "play next" on a
|
||||
song already in the queue would create a confusing duplicate.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `prev` with empty history in Sequential mode | Walks `currentIndex - 1`; returns null at index 0 (existing) |
|
||||
| `prev` with empty history in Random/RandomLoop | Returns null (no past to recover) |
|
||||
| Repeated `prev` past start of history | Pops what's there, then falls back to index walk; eventually null |
|
||||
| `addNext` while `currentIndex == -1` (nothing played yet) | Falls through to push; queue.play() will pick it as first |
|
||||
| `addNext` while playing and queue size = 1 | Inserts at index 1; current index unchanged; next() will advance to it |
|
||||
| `remove` removes a song whose index is in history | Entry dropped; shifted accordingly |
|
||||
| Mode switched mid-playback | History cleared (intentional — mode change is a context boundary) |
|
||||
| `addNext` then `prev` | Inserted song was never played → not in history; prev pops the previously-played song, NOT the just-inserted one |
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `src/audio/queue.ts` — history field, `pushHistory`, `addNext`, rewritten `prev`, mutator updates
|
||||
- `src/audio/queue.test.ts` (or add if missing) — unit tests for history behavior + addNext shift logic
|
||||
- `src/bot/instance.ts` — register `!playnext` / `!pn` command handler
|
||||
- `src/web/api/player.ts` — new `/play-next-song` route
|
||||
- `web/src/stores/player.ts` — `playNextSong` action
|
||||
- `web/src/components/SongCard.vue` — third action button + emit
|
||||
- `web/src/views/Home.vue` — wire `@playNext`
|
||||
- `web/src/views/Library.vue` — wire `@playNext`
|
||||
- `web/src/views/Search.vue` — wire `@playNext`
|
||||
- `web/src/views/History.vue` — wire `@playNext`
|
||||
- `web/src/views/Playlist.vue` — wire `@playNext`
|
||||
|
||||
## Test Plan
|
||||
|
||||
**Unit (vitest, `queue.test.ts`):**
|
||||
- prev with empty history in Sequential: walks back, null at index 0
|
||||
- prev with empty history in Random: returns null
|
||||
- next → next → next → prev pops correctly; prev again pops earlier
|
||||
- prev after `clear()` returns null (history reset)
|
||||
- prev after `setMode()` returns null (history reset)
|
||||
- `remove(idx)` drops from history and shifts entries > idx
|
||||
- `addNext` while empty: appends
|
||||
- `addNext` while playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6
|
||||
- `addNext` then `next()`: plays the inserted song
|
||||
- `addNext` shifts existing playedIndices and history correctly
|
||||
|
||||
**Integration (manual smoke):**
|
||||
- Random mode: play 4 songs, hit `prev` 3 times → walks back through history
|
||||
- Click "下一首播放" on a search result → next song after current is the chosen one
|
||||
- `!playnext 七里香` → bot replies "已加入下一首:..."; current keeps playing; next song is 七里香
|
||||
- `!playnext` while idle → starts playing immediately
|
||||
|
||||
**Regression:**
|
||||
- Existing 161 source-tree tests still pass.
|
||||
- TypeScript `tsc --noEmit` and `npm run build:web` clean.
|
||||
@@ -0,0 +1,149 @@
|
||||
# 自定义机器人头像 + 专辑搜索/播放
|
||||
|
||||
**Date:** 2026-05-07
|
||||
**Status:** Spec — pending implementation
|
||||
**Issue:** [#51](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/51)
|
||||
|
||||
## Problem
|
||||
|
||||
Issue #51 的两个独立但同源的反馈:
|
||||
|
||||
1. **机器人头像无法固定** — 当前 `ProfileConfig.avatarEnabled` 控制是否同步专辑封面,但没有任何"上传一张固定头像"的入口。多 bot 房间里用户依赖头像辨识具体 bot,封面跟着歌变会让识别成本变高。
|
||||
2. **网页端搜索不能播放整张专辑** — `SearchResult.albums` 类型字段存在但所有 provider 都返回 `[]`,搜索 API `/search/all` 只聚合 `songs`;Search.vue 里也只渲染 SongCard。Netease 后端 `getAlbumSongs` 已实现,唯独缺把搜索/UI 连起来。
|
||||
|
||||
两件事独立,分两个 PR;本 spec 同时覆盖两块以保持 #51 的单一 issue 关系。
|
||||
|
||||
## Goal
|
||||
|
||||
### 自定义头像
|
||||
|
||||
- "创建新实例"弹窗里有一个"自定义头像"上传/预览控件(PNG/JPG/WebP,≤200 KB,与 TS3 头像上限一致)
|
||||
- Settings 已有的"同步头像"那行下面增加同等的"自定义头像"卡片,可以在已存在的 bot 上随时改/删
|
||||
- 行为矩阵:
|
||||
|
||||
| `avatarEnabled` | 有自定义 | 播放时 | 停播时 |
|
||||
|---|---|---|---|
|
||||
| true | 是 | 跟当前歌曲封面 | **回到自定义** |
|
||||
| true | 否 | 跟当前歌曲封面 | 清空(保持现状) |
|
||||
| false | 是 | 一直显示自定义 | 一直显示自定义 |
|
||||
| false | 否 | 不主动改 | 不主动改 |
|
||||
|
||||
### 专辑搜索
|
||||
|
||||
- 搜索结果里能看到"专辑"分区(先支持 Netease + QQ,bilibili/youtube 仍返回 `[]`)
|
||||
- 点专辑卡片进入详情页 → 看到曲目列表 + 顶部"播放全部 / 加入队列"
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 头像格式自动转换(用户传 GIF/BMP 不接受,前端校验拒掉)
|
||||
- 头像服务端自动 resize(本期保持"上传时校验大小",后期可加 sharp/jimp 但不在本期)
|
||||
- 专辑搜索的多平台聚合排序(按 `netease → qq` 简单拼接,与现有 `songs` 聚合一致)
|
||||
- 专辑详情页的"喜欢/收藏"按钮(playlist 详情页本身也没有)
|
||||
- 专辑作为推荐位(Home 不出现"推荐专辑"这一栏)
|
||||
- bilibili / youtube 的专辑概念(这两个平台无对应 API)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 自定义头像
|
||||
|
||||
#### 存储
|
||||
|
||||
- 文件落地 `data/avatars/<botId>.<ext>`(仿 `data/cookies/<platform>.json`,Docker volume 友好)
|
||||
- DB schema:`bot_instances` 表新增 `custom_avatar_path` TEXT NULL(存相对路径,如 `avatars/<botId>.png`),通过 `migrateSchema()` 迁移
|
||||
- 加载时机:`BotProfileManager` 构造时把文件读到内存 `Buffer`,避免每次 stop 都读盘
|
||||
|
||||
#### 后端 API
|
||||
|
||||
新增 `src/web/api/bot.ts` 里(如不存在则在 `instance.ts` 同源处):
|
||||
|
||||
- `POST /api/bot/:id/avatar` (multipart) — 校验大小 ≤200 KB、MIME ∈ {png,jpeg,webp};写盘 + 更新 DB;广播给运行中实例(重新加载 buffer + 立即 `applyIdleAvatar()`)
|
||||
- `DELETE /api/bot/:id/avatar` — 删盘 + 清 DB;运行中实例切回原 clear 语义
|
||||
- `GET /api/bot/:id/avatar` — 直接 `res.sendFile`(带强 ETag)供前端预览
|
||||
|
||||
#### `BotProfileManager` 改动
|
||||
|
||||
新增字段 + 方法:
|
||||
|
||||
```ts
|
||||
private customAvatar: Buffer | null = null;
|
||||
|
||||
setCustomAvatar(buf: Buffer | null): void;
|
||||
private async applyIdleAvatar(gen: number): Promise<void>; // 上传 customAvatar
|
||||
```
|
||||
|
||||
修改:
|
||||
|
||||
- `clearAvatar(gen)` → `if (this.customAvatar) { applyIdleAvatar(gen) } else { 当前逻辑 }`
|
||||
- `onConnect()` 新增:`if (!avatarEnabled && customAvatar) applyIdleAvatar(gen)`
|
||||
- `setCustomAvatar(buf)`:更新内存 buffer,并触发 `applyIdleAvatar` 一次(仅当当前应该显示 idle avatar 时,即没在播放或 avatarEnabled=false)
|
||||
|
||||
#### 前端
|
||||
|
||||
新组件 `web/src/components/AvatarUpload.vue`:
|
||||
|
||||
- props: `botId?` (上传时空表示走临时 base64 缓存)、`v-model:value`
|
||||
- 拖拽 / 文件选择 / 预览圆框 / 删除按钮
|
||||
- 内部 `axios.post('/api/bot/<id>/avatar', formData)` 或在创建表单里把 base64 与表单一同提交
|
||||
|
||||
接入点:
|
||||
|
||||
- 创建实例弹窗(搜索 `BotEditor.vue` 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘
|
||||
- Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 `<AvatarUpload :bot-id="botId" />`
|
||||
|
||||
### 专辑搜索 / 详情
|
||||
|
||||
#### 后端
|
||||
|
||||
`src/music/netease.ts` `search()`:
|
||||
|
||||
- 多发一个 `cloudsearch?type=10` 请求,把返回的 `result.albums[]` 映射成 `Album[]` 填进 `SearchResult.albums`
|
||||
- 字段 `id` / `name` / `coverUrl` (`picUrl`) / `artist` (`artists[].name.join(' / ')`)
|
||||
|
||||
`src/music/qq.ts` `search()`:
|
||||
|
||||
- 在现有 `req_0` 旁增加 `req_album: { module: "music.search.SearchCgiService", method: "DoSearchForQQMusicDesktop", param: { searchid, query, search_type: 8 } }`,映射 `body.album.list[]`
|
||||
|
||||
`src/web/api/music.ts` `/search/all`:
|
||||
|
||||
- 在响应里增加 `albums` 和 `playlists`,与 `songs` 一同合并
|
||||
|
||||
`/album/:id` 已存在,无需改动。
|
||||
|
||||
#### 前端
|
||||
|
||||
- `web/src/views/Search.vue`:响应 schema 升级为 `{songs, albums, playlists}`;模板加入两个新分区("专辑"、"歌单"),各自一个简单的卡片网格(参考 Home.vue 的 `playlist-grid`)
|
||||
- 新路由 `/album/:id` → 复用 `Playlist.vue`,把它的 `loadPlaylist()` 重构为根据 `route.path` 决定调 `/playlist/:id` 还是 `/album/:id`,或者新建 `Album.vue` 内部 import 同一个 `<PlaylistDetail />` 子组件
|
||||
- **方案选择**:拆出 `<PlaylistDetail :endpoint="...">` 组件 + `Album.vue` / `Playlist.vue` 两个薄壳。当前 `Playlist.vue` 内部仅 ~60 行模板,单文件改造比新建 PlaylistDetail 子组件更小,先用最小改动:在 `Playlist.vue` 内根据 `route.meta.kind === 'album'` 切换 endpoint
|
||||
- 路由:`router/index.ts` 加 `{ path: '/album/:id', component: Playlist, meta: { kind: 'album' } }`
|
||||
|
||||
### 拆分
|
||||
|
||||
**两个 PR:**
|
||||
|
||||
1. `feat(profile): custom bot avatar with idle/playback precedence`
|
||||
- DB migration + ProfileManager 改动 + 上传 API + AvatarUpload.vue + 接入两个表单
|
||||
2. `feat(search): album section in search results + album detail playback`
|
||||
- netease/qq search 扩展 + /search/all + Search.vue 分区 + Playlist.vue 复用为 album
|
||||
|
||||
## Testing
|
||||
|
||||
### 自定义头像
|
||||
|
||||
- 单元:DB 迁移加 `custom_avatar_path` 列幂等;上传 API 校验大小/MIME;ProfileManager.applyIdleAvatar 在 onSongChange(null) 后被调用
|
||||
- 集成:mock TS3Client 验证 fileTransferInitUpload 收到的 buffer 是 customAvatar
|
||||
- 手动:本地起 bot 上传一张 png → 检查头像;播一首歌 → 头像切封面;停止 → 头像回到 png;关掉 avatarEnabled 重启 → 头像直接是 png
|
||||
|
||||
### 专辑搜索
|
||||
|
||||
- 单元:netease/qq `search()` 测试:响应包含 albums 字段,长度 > 0 (mock fixture 必须含 album 段)
|
||||
- 集成:`/search/all` 响应 schema 包含 `albums`/`playlists`
|
||||
- 手动:搜"周杰伦" → 看到歌曲 + 专辑 + 歌单三个分区;点专辑 → 详情页 → 播放全部 → 队列加上整张专辑
|
||||
|
||||
## Migration
|
||||
|
||||
DB 迁移:`bot_instances.custom_avatar_path` TEXT NULL,默认 NULL。已存在 bot 不受影响。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- TS6 协议路径下 `fileTransferInitUpload` 是否一致?(既有 avatar 流程已经覆盖 TS3 + TS6,本期沿用同一路径,不单独验证)
|
||||
- 头像超过 200 KB 时前端用 Canvas 自动 resize 还是直接拒?— **决定:拒,错误提示"请压缩到 200KB 以内"**,简单可控
|
||||
@@ -0,0 +1,360 @@
|
||||
# WebUI Authentication
|
||||
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Spec — pending implementation
|
||||
**Branch:** `feat/webui-auth`
|
||||
|
||||
## Problem
|
||||
|
||||
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
|
||||
|
||||
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
|
||||
- 静态前端通过 `express.static()` 直接对外提供。
|
||||
|
||||
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
|
||||
|
||||
## Goal
|
||||
|
||||
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
|
||||
|
||||
## Out of Scope(明确不做)
|
||||
|
||||
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
|
||||
- 角色与权限(admin / viewer)—— 全员同权
|
||||
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
|
||||
- 双因素认证(2FA)
|
||||
- "记住我" / 绝对过期 vs 滑动过期的可配置
|
||||
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
|
||||
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
|
||||
|
||||
## Non-functional Constraints
|
||||
|
||||
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
|
||||
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
|
||||
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
|
||||
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
|
||||
|
||||
## Architecture
|
||||
|
||||
### 数据层(`src/data/`)
|
||||
|
||||
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, -- uuid v4
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
|
||||
createdAt INTEGER NOT NULL,
|
||||
updatedAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
|
||||
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
createdAt INTEGER NOT NULL,
|
||||
expiresAt INTEGER NOT NULL, -- ms epoch
|
||||
lastSeenAt INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
||||
```
|
||||
|
||||
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
|
||||
|
||||
新模块:
|
||||
|
||||
`src/data/users.ts`
|
||||
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
|
||||
- `findByUsername(username): User | null`
|
||||
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
|
||||
- `countUsers(): number` — 用于 `/needs-setup`
|
||||
- `changePassword(userId, newPassword): void`
|
||||
|
||||
`src/data/sessions.ts`
|
||||
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
|
||||
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
|
||||
- `deleteSession(rawToken): void` — 退出
|
||||
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
|
||||
- `cleanupExpired(): void` — 定时任务
|
||||
|
||||
### HTTP 层(`src/web/`)
|
||||
|
||||
#### 新增中间件
|
||||
|
||||
`src/web/middleware/requireAuth.ts`
|
||||
```
|
||||
读取 req.cookies.tsmb_session
|
||||
→ 缺失 → 401 { error: "unauthenticated" }
|
||||
→ 调 sessions.validateAndTouch
|
||||
→ null → 清 cookie + 401
|
||||
→ 有效 → req.user = { id, username }; next()
|
||||
```
|
||||
|
||||
`src/web/middleware/csrf.ts`
|
||||
```
|
||||
若 method ∈ {GET, HEAD, OPTIONS} → next()
|
||||
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
|
||||
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
|
||||
```
|
||||
|
||||
#### 新路由:`src/web/api/session.ts`
|
||||
|
||||
挂在 `/api/session`,全部公共(不挂 requireAuth):
|
||||
|
||||
| Method | Path | 行为 |
|
||||
|---|---|---|
|
||||
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
|
||||
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
|
||||
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
|
||||
| POST | `/logout` | 删 session,清 cookie,204 |
|
||||
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
|
||||
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
|
||||
|
||||
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
|
||||
|
||||
#### Cookie 规范
|
||||
|
||||
- 名称:`tsmb_session`
|
||||
- 值:32 字节 random → base64url
|
||||
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
|
||||
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
|
||||
|
||||
#### 装配顺序(`src/web/server.ts`)
|
||||
|
||||
```ts
|
||||
app.use(express.json({ limit: "400kb" }));
|
||||
app.use(cookieParser()); // 新增
|
||||
|
||||
// 公共
|
||||
app.get("/api/health", …);
|
||||
app.get("/api/config/public-url", …);
|
||||
app.use("/api/session", createSessionRouter(...));
|
||||
|
||||
// 闸门(仅作用于下方注册的 /api/* 路由)
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
// 受保护
|
||||
app.use("/api/bot", createBotRouter(...));
|
||||
app.use("/api/music", createMusicRouter(...));
|
||||
app.use("/api/player", createPlayerRouter(...));
|
||||
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
|
||||
|
||||
// 静态 SPA(公共,前端自行判定登录态后跳转)
|
||||
app.use(express.static(staticDir));
|
||||
app.get(/^(?!\/api|\/ws)/, sendIndex);
|
||||
```
|
||||
|
||||
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
|
||||
|
||||
#### 定时清理
|
||||
|
||||
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
|
||||
|
||||
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
|
||||
|
||||
改造为手动 upgrade:
|
||||
|
||||
```ts
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
if (req.url !== "/ws") { socket.destroy(); return; }
|
||||
const session = validateCookieFromHeaders(req.headers.cookie);
|
||||
if (!session) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
(ws as any).userId = session.userId;
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
|
||||
|
||||
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
|
||||
|
||||
### 前端层(`web/`)
|
||||
|
||||
#### 新视图
|
||||
|
||||
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
|
||||
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
|
||||
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
|
||||
|
||||
#### Session 状态
|
||||
|
||||
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
|
||||
|
||||
#### 路由守卫(`web/src/router/index.ts`)
|
||||
|
||||
- 公共路由:`/login`、`/setup`
|
||||
- 全局 `beforeEach`:
|
||||
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
|
||||
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
|
||||
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
|
||||
|
||||
#### API 客户端
|
||||
|
||||
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
|
||||
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
|
||||
|
||||
#### UI
|
||||
|
||||
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
|
||||
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
|
||||
|
||||
### 依赖
|
||||
|
||||
新增到 `package.json`:
|
||||
|
||||
```
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7"
|
||||
```
|
||||
|
||||
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 首次启动
|
||||
|
||||
```
|
||||
Browser → GET / → static SPA
|
||||
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
|
||||
SPA → router.replace('/setup')
|
||||
User submits form → POST /api/session/setup
|
||||
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
|
||||
SPA → currentUser refresh → router.replace('/')
|
||||
```
|
||||
|
||||
### 已部署用户升级
|
||||
|
||||
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
|
||||
|
||||
### 后续登录
|
||||
|
||||
```
|
||||
SPA → GET /api/session/me → 401
|
||||
SPA → router.replace('/login?next=/queue')
|
||||
User submits → POST /api/session/login → Set-Cookie + 200
|
||||
SPA → currentUser refresh → router.replace('/queue')
|
||||
```
|
||||
|
||||
### 受保护请求
|
||||
|
||||
```
|
||||
SPA → fetch('/api/bot', { credentials: 'same-origin' })
|
||||
Server requireAuth: validateAndTouch(cookie)
|
||||
→ ok → req.user 注入 → 业务路由处理
|
||||
→ 不 ok → 401 → SPA 拦截器跳 /login
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
|
||||
```
|
||||
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
|
||||
Server upgrade handler: validateCookieFromHeaders
|
||||
→ ok → handleUpgrade → connection event
|
||||
→ 不 ok → HTTP 401 写回原始 socket → destroy
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| 场景 | HTTP 响应 | 备注 |
|
||||
|---|---|---|
|
||||
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
|
||||
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
|
||||
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
|
||||
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
|
||||
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
|
||||
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
|
||||
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
|
||||
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
|
||||
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
|
||||
|
||||
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元(vitest)
|
||||
|
||||
`src/data/users.test.ts`
|
||||
- createUser 成功后 findByUsername 命中(大小写不敏感)
|
||||
- 重复 username 抛 UsernameTakenError
|
||||
- verifyPassword 正反例
|
||||
- changePassword 之后旧哈希不再验证通过
|
||||
|
||||
`src/data/sessions.test.ts`
|
||||
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
|
||||
- validateAndTouch 过期记录返回 null 且记录被删
|
||||
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
|
||||
- deleteAllForUser(exceptToken) 保留指定会话
|
||||
|
||||
### 集成(vitest + supertest,真 SQLite in-memory)
|
||||
|
||||
`src/web/api/session.test.ts`
|
||||
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
|
||||
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
|
||||
- /logout 之后同一 cookie 调受保护路由 401
|
||||
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
|
||||
|
||||
`src/web/middleware/csrf.test.ts`
|
||||
- 带匹配 Origin 的 POST 通过
|
||||
- Origin 与 host 不匹配 → 403
|
||||
- 同样规则适用 Referer
|
||||
- GET 永远通过
|
||||
|
||||
`src/web/websocket.test.ts`(新增或扩展)
|
||||
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
|
||||
- 带有效 cookie → 握手成功,收到 init 消息
|
||||
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
|
||||
|
||||
### 前端
|
||||
|
||||
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
|
||||
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
|
||||
- 退出 → 自动跳 /login
|
||||
- 关闭浏览器 7 天内再开 → 仍登录
|
||||
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
src/data/database.ts (schema migration)
|
||||
src/data/users.ts (new)
|
||||
src/data/users.test.ts (new)
|
||||
src/data/sessions.ts (new)
|
||||
src/data/sessions.test.ts (new)
|
||||
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
|
||||
src/web/middleware/requireAuth.ts (new)
|
||||
src/web/middleware/csrf.ts (new)
|
||||
src/web/middleware/csrf.test.ts (new)
|
||||
src/web/api/session.ts (new)
|
||||
src/web/api/session.test.ts (new)
|
||||
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
|
||||
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
|
||||
src/web/websocket.test.ts (新增 / 扩展)
|
||||
package.json (deps)
|
||||
|
||||
web/src/views/Login.vue (new)
|
||||
web/src/views/FirstRunSetup.vue (new)
|
||||
web/src/composables/useSession.ts (new)
|
||||
web/src/router/index.ts (公共路由 + beforeEach 守卫)
|
||||
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
|
||||
web/src/App.vue (顶栏 logout + 当前用户名)
|
||||
```
|
||||
|
||||
## Trade-offs / 已知妥协
|
||||
|
||||
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
|
||||
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
|
||||
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
|
||||
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
|
||||
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
|
||||
@@ -0,0 +1,167 @@
|
||||
# Fine-grained account permissions — design
|
||||
|
||||
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Scope
|
||||
|
||||
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
|
||||
grant each non-admin (member) account a set of capabilities and a list of bots they
|
||||
may control. The other items are handled in separate PRs and are **out of scope**
|
||||
here:
|
||||
|
||||
- #1 Guest mode (login-less playback)
|
||||
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
|
||||
- #3 Auto-pause when channel empty
|
||||
- #4 Dedicated link loses bot binding on refresh (a bug)
|
||||
|
||||
## Problem
|
||||
|
||||
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
|
||||
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
|
||||
**Every other action — create/edit/delete bots, start/stop, all playback & queue
|
||||
control, set platform login cookies, set audio quality — is open to any logged-in
|
||||
member, on every bot.** Admins want to delegate limited control to members without
|
||||
handing them full power.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
|
||||
matrix, not role templates).
|
||||
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
|
||||
all bots (no behavior change); newly-created members get a **basic tier**.
|
||||
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
|
||||
empty list = no bots controllable. Members **cannot see** bots outside their
|
||||
allow-list (hidden, not merely disabled).
|
||||
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
|
||||
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
|
||||
(existing invariant preserved). Permission grants/revokes are written to the
|
||||
existing audit log.
|
||||
|
||||
## Capability taxonomy
|
||||
|
||||
| Capability token | Covers | Scope |
|
||||
|---|---|---|
|
||||
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
|
||||
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
|
||||
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
|
||||
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
|
||||
| `quality` | set audio quality per platform | **global** |
|
||||
|
||||
Bot scope is independent of capabilities: a member with `player.control` can only
|
||||
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
|
||||
`platform.auth` and `quality` are global capabilities with no bot scope.
|
||||
|
||||
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
|
||||
A new member can play/queue on every bot but cannot manage bots, change credentials,
|
||||
or change quality.
|
||||
|
||||
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
|
||||
|
||||
```sql
|
||||
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
userId TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, permission),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
|
||||
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||
userId TEXT NOT NULL,
|
||||
botId TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, botId),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||
```
|
||||
|
||||
- Admins have no rows (they bypass). Only members are constrained.
|
||||
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
|
||||
rows; empty ⇒ none.
|
||||
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
|
||||
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
|
||||
access rows should be cleaned up (either an FK to the bot table if one exists, or an
|
||||
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
|
||||
|
||||
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
|
||||
`createSessionStore`: prepared statements + an interface). Methods:
|
||||
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
|
||||
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
|
||||
|
||||
## Backend enforcement (real 403 — not just hidden UI)
|
||||
|
||||
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
|
||||
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
|
||||
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
|
||||
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
|
||||
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
|
||||
Because it's read live, permission changes take effect immediately (no re-login).
|
||||
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
|
||||
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
|
||||
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
|
||||
else 403. Mounted on the player router's existing `/:botId` choke-point
|
||||
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
|
||||
(start/stop/edit/delete/avatar/profile).
|
||||
- **Route → capability mapping:**
|
||||
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
|
||||
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
|
||||
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
|
||||
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
|
||||
- `/api/music/quality` POST → `quality`
|
||||
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
|
||||
(admins see all). This is what "hides" disallowed bots in the UI.
|
||||
|
||||
## Management API (admin-only, added to the existing users router)
|
||||
|
||||
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
|
||||
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
|
||||
against the known set and botIds against existing bots; writes audit
|
||||
`user.permissions_changed`.
|
||||
- `GET /api/session/me` is extended to include the **current** user's
|
||||
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
|
||||
|
||||
## Frontend
|
||||
|
||||
- `useSession` extends `User` with `capabilities` + bot scope and exposes
|
||||
`can(cap)` and `canControlBot(botId)` helpers.
|
||||
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
|
||||
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
|
||||
- **Player / Settings** hide controls and whole sections a member lacks: platform
|
||||
login, audio quality, and bot create/edit/delete are hidden without the matching
|
||||
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
|
||||
- **Admin permission editor:** in the Settings → User Management list, each member row
|
||||
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
|
||||
toggle. Saving calls `PUT /api/users/:id/permissions`.
|
||||
|
||||
## Defaults & migration
|
||||
|
||||
- New tables created idempotently in `initTables`.
|
||||
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
|
||||
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
|
||||
current behavior for existing members on upgrade.
|
||||
- **New member default** (`POST /api/users` with role member): capabilities
|
||||
`{ player.control, player.queue }` + `bots.all` (basic tier).
|
||||
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
|
||||
super-users and unaffected.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
|
||||
empty; `pruneBot`).
|
||||
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
|
||||
cap → 200/403; bot in/out of allow-list; `bots.all`).
|
||||
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
|
||||
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
|
||||
+ audits.
|
||||
- Migration test: existing members backfilled to full + `bots.all`; new member gets
|
||||
basic tier.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
|
||||
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
|
||||
- No change to the admin/member role concept itself; this layers capabilities under
|
||||
the existing `member` role.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Auto-pause on empty channel — design
|
||||
|
||||
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
|
||||
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
|
||||
and resume when someone returns.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
|
||||
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
|
||||
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
|
||||
enter/leave/move events; keep the existing 30s idle poll as a fallback.
|
||||
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
|
||||
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
|
||||
emptiness signal but act independently (pause immediately; disconnect after N minutes).
|
||||
|
||||
## Current state (verified)
|
||||
|
||||
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
|
||||
bot*; callers compute "others" as `length - 1`. No persistent roster.
|
||||
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
|
||||
*logs* `clientEnter` and does not re-emit leave/moved.
|
||||
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
|
||||
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
|
||||
idle-disconnect after `idleTimeoutMinutes`.
|
||||
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
|
||||
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
|
||||
auto-vs-user-pause distinction today.
|
||||
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
|
||||
|
||||
## Design
|
||||
|
||||
### Occupancy signal (shared)
|
||||
Extract the idle poller's count into one method on `BotInstance`:
|
||||
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
|
||||
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
|
||||
auto-pause logic below. It is called by:
|
||||
1. the existing 30s poll (fallback), and
|
||||
2. new TS event handlers.
|
||||
|
||||
### Event subscription
|
||||
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
|
||||
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
|
||||
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
|
||||
the poll remains as a safety net.
|
||||
|
||||
### Auto-pause logic (inside `checkChannelOccupancy`)
|
||||
Add a private `autoPaused = false` flag to `BotInstance`.
|
||||
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
|
||||
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
|
||||
scheduled as today.)
|
||||
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
|
||||
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
|
||||
|
||||
### `autoPaused` bookkeeping (so user pauses are respected)
|
||||
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
|
||||
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
|
||||
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
|
||||
|
||||
### Config wiring
|
||||
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
|
||||
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
|
||||
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
|
||||
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
|
||||
can be as simple as updating the stored config reference / a field the check reads.
|
||||
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
|
||||
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
|
||||
it on save.
|
||||
|
||||
## Components / files
|
||||
|
||||
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
|
||||
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
|
||||
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
|
||||
connect/disconnect; `updateAutoPause(enabled)`.
|
||||
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
|
||||
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
|
||||
- `src/data/config.ts` — field already exists (no change beyond confirming default).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
|
||||
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
|
||||
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
|
||||
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
|
||||
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
|
||||
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
|
||||
persists, propagates).
|
||||
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||
no extra channel subscription needed.
|
||||
|
||||
---
|
||||
|
||||
## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered
|
||||
|
||||
Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2)
|
||||
invalidated two assumptions above. Recording the corrected model here so nobody
|
||||
reintroduces the old design:
|
||||
|
||||
- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts`
|
||||
and the rationale comment there.
|
||||
- **Query commands are unusable when others are present.** `clientlist`,
|
||||
`channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2
|
||||
clients are connected to the **server** (verified even when the two clients
|
||||
are in *different* channels). They succeed only when the bot is the sole
|
||||
client on the whole server. So `getClientsInChannel()` returns `[]` exactly
|
||||
when occupancy matters, and `occupancyFromClientList(0)` returns `null`
|
||||
("unknown") so callers skip the decision rather than mis-reading it as empty.
|
||||
- **PAUSE** therefore only ever fires when the bot becomes alone on the server
|
||||
(the one state where the query works). This is reliable and stays on the
|
||||
query path (`refreshOccupancy()` + the 30s idle poller).
|
||||
- **RESUME** is armed directly from the `clientEnter` push event
|
||||
(`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from
|
||||
a query. Because the bot only auto-pauses while alone, the sole way occupancy
|
||||
can return while `autoPaused` is set is a fresh connection — delivered as
|
||||
`clientEnter`. The resume branch never pauses (userCount is always > 0).
|
||||
- **Net semantics:** "pause when the server is empty (bot alone), resume when
|
||||
someone connects." Channel granularity is **impossible** with this library:
|
||||
`clientEnter`'s channel field is always `0` (library reads notify param `cid`
|
||||
but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT
|
||||
attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is
|
||||
systematically wrong for direct-connect clients and reintroduces unreliability.
|
||||
The correct path to true channel scoping is an upstream library fix.
|
||||
- **Knock-on:** idle-disconnect shares the same signal and is likewise
|
||||
server-wide. UI copy in `web/src/views/Settings.vue` was updated to say
|
||||
"服务器" rather than "频道" to match. `cmdVote` was intentionally left on the
|
||||
query path (out of scope; switching it would inherit the same timeout).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Dedicated-link bot scoping (+ refresh fix) — design
|
||||
|
||||
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
|
||||
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
|
||||
|
||||
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
|
||||
|
||||
## Decision (from brainstorm: Q2 = URL-carried scope)
|
||||
|
||||
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope state (store)
|
||||
Add to the player store:
|
||||
- `scopedBotId: string | null` — the bot the UI is locked to.
|
||||
- getter `isScoped` = `scopedBotId !== null`.
|
||||
- action `setScope(id)` / `clearScope()`.
|
||||
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
|
||||
|
||||
### URL as the durable source of truth
|
||||
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
|
||||
- **Router `beforeEach` guard** (the heart of it):
|
||||
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
|
||||
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
|
||||
- Else → no scope; continue.
|
||||
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
|
||||
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
|
||||
|
||||
### Exit
|
||||
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
|
||||
|
||||
### Navbar (the lock UI)
|
||||
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
|
||||
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
|
||||
- When not scoped: unchanged (full selector over `controllableBots`).
|
||||
|
||||
### Active-bot coherence
|
||||
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
|
||||
|
||||
## Components / files
|
||||
|
||||
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
|
||||
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
|
||||
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
|
||||
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
|
||||
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
|
||||
|
||||
## Testing
|
||||
|
||||
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
|
||||
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
|
||||
- `clearScope` resets state.
|
||||
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
|
||||
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
|
||||
@@ -0,0 +1,317 @@
|
||||
# Guest mode (login-less WebUI access) — design
|
||||
|
||||
**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能"
|
||||
**Date:** 2026-06-24
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Scope
|
||||
|
||||
Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can
|
||||
reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest")
|
||||
and use a restricted subset of playback/queue features. The admin chooses, per
|
||||
deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may
|
||||
control. Guests can **never** view or change settings, manage bots, set platform
|
||||
credentials, change audio quality, or see the user/audit admin panels.
|
||||
|
||||
This builds directly on the existing `admin | member` role + capability system
|
||||
(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing
|
||||
append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild
|
||||
auth.
|
||||
|
||||
The original issue asked specifically that guest song requests go to "下一首" only.
|
||||
That exact behavior is reproducible in this design by the admin turning the
|
||||
"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration
|
||||
of a more general per-ability toggle model (chosen in brainstorm).
|
||||
|
||||
## Problem
|
||||
|
||||
Today every `/api/*` route past the session router requires a real account
|
||||
(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an
|
||||
admin must create them a `member` account. The maintainer wants a low-friction,
|
||||
admin-gated way to let untrusted visitors request music without an account, while
|
||||
keeping all administration locked down.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal**
|
||||
(`role: "guest"`), not a database user with a password. No favorites, no
|
||||
change-password, short-lived session.
|
||||
2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when
|
||||
the flag is off — never rely on hiding the button).
|
||||
3. **Per-ability toggles**, not a single "mode". Every song action and control action is
|
||||
its own admin switch. Default state when guest mode is first enabled: only
|
||||
"add to end of queue" is ON; everything else OFF.
|
||||
4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing
|
||||
member bot allow-list. Guests cannot see or control out-of-scope bots — including
|
||||
over WebSocket.
|
||||
5. **Settings are always hidden AND server-blocked for guests** (view + change), closing
|
||||
the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`).
|
||||
6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the
|
||||
existing clear-the-whole-queue `/play-song` behavior.
|
||||
7. **One unified authorization gate** encapsulates admin/member/guest logic so the
|
||||
existing member/admin capability system is left behavior-unchanged.
|
||||
|
||||
## Guest ability model
|
||||
|
||||
### Always allowed (baseline read-only — the point of the feature)
|
||||
- Browse/search library, playlists, history, song detail, lyrics, cover art.
|
||||
- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**.
|
||||
|
||||
### Always denied (hard locks — not toggles)
|
||||
- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
|
||||
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
|
||||
- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`).
|
||||
- User management (`/api/users`), audit log (`/api/audit`), change-password.
|
||||
|
||||
### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`)
|
||||
|
||||
| Flag | 中文 | Default | Backend route(s) gated |
|
||||
|---|---|---|---|
|
||||
| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` |
|
||||
| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` |
|
||||
| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
|
||||
| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` |
|
||||
| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` |
|
||||
| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` |
|
||||
| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` |
|
||||
|
||||
Notes:
|
||||
- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`,
|
||||
`/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit)
|
||||
are **never** reachable by guests — the gate denies any guest without an explicit flag.
|
||||
This is the safe default: new routes are guest-denied unless deliberately opted in.
|
||||
- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay
|
||||
guest-denied regardless of toggles (they would wipe everyone's queue).
|
||||
|
||||
## Config schema (`src/data/config.ts`)
|
||||
|
||||
```ts
|
||||
export interface GuestPermissions {
|
||||
addToQueue: boolean; // append to end
|
||||
playNext: boolean; // 下一首 (insert after current)
|
||||
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
|
||||
skip: boolean; // skip current track
|
||||
transport: boolean; // pause/resume/seek/volume
|
||||
removeClear: boolean; // remove a queue item / clear the queue
|
||||
playMode: boolean; // play mode (shuffle/repeat) + FM
|
||||
}
|
||||
|
||||
export interface GuestModeConfig {
|
||||
enabled: boolean; // master switch, default false
|
||||
bots: "all" | string[]; // per-bot scope (botIds); default "all"
|
||||
permissions: GuestPermissions;
|
||||
}
|
||||
|
||||
// added to BotConfig:
|
||||
guestMode: GuestModeConfig;
|
||||
```
|
||||
|
||||
`getDefaultConfig()` returns:
|
||||
```ts
|
||||
guestMode: {
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`,
|
||||
which would drop `guestMode` sub-keys if a saved config only contains a partial
|
||||
`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over
|
||||
the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case.
|
||||
|
||||
**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an
|
||||
array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the
|
||||
same `BotManager.removeBot` path that already prunes member access.
|
||||
|
||||
## Backend design
|
||||
|
||||
### Synthetic guest principal & session entry
|
||||
- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in
|
||||
`src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend
|
||||
`User` type, and the role badge in Navbar).
|
||||
- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`,
|
||||
an unusable password hash, username e.g. `"guest"`) is created idempotently by
|
||||
migration. It exists only to satisfy the `sessions.userId` FK and the
|
||||
`validateAndTouch` JOIN; it is excluded from user-management listings and the
|
||||
last-admin guards (those count `role = 'admin'` only, so guests don't interfere).
|
||||
- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block
|
||||
(before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited.
|
||||
- If `config.guestMode.enabled` is false → `403 guest mode disabled`.
|
||||
- Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly
|
||||
cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and
|
||||
**bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11
|
||||
would evict guest #1). Expired guest sessions are already deleted on validation; an
|
||||
optional periodic sweep can prune stale ones.
|
||||
- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session
|
||||
has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as
|
||||
unauthenticated (401). So flipping guest mode off immediately ends guest access.
|
||||
- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling
|
||||
`GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public**
|
||||
Login page can decide whether to show the guest button. This must not leak any other
|
||||
config.
|
||||
|
||||
### Permission resolution
|
||||
`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the
|
||||
live guest config:
|
||||
```ts
|
||||
resolvePermissionContext(role, userId, store, guestConfig?) => {
|
||||
admin → { capabilities: all CAPABILITIES, bots: "all" }
|
||||
member → stored caps + stored bots // unchanged
|
||||
guest → {
|
||||
capabilities: new Set(), // holds NO member capabilities
|
||||
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
|
||||
guest: guestConfig.permissions, // resolved per-request from live config
|
||||
}
|
||||
}
|
||||
```
|
||||
`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because
|
||||
`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on
|
||||
the guest's next request (no re-login).
|
||||
|
||||
### Unified authorization gate (`src/web/middleware/authorize.ts`, new)
|
||||
Replaces `requirePermission('x')` on **guest-reachable** routes:
|
||||
```ts
|
||||
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
|
||||
// 401 if no req.user
|
||||
// admin → next()
|
||||
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
|
||||
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
|
||||
```
|
||||
- Member/admin semantics are **identical** to today's `requirePermission`.
|
||||
- A route with no `guestFlag` is automatically guest-denied (safe default).
|
||||
- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests
|
||||
flow through `req.user.bots`).
|
||||
- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and
|
||||
`/api/audit` stay locked.
|
||||
|
||||
### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`)
|
||||
- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`:
|
||||
- `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }`
|
||||
- `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }`
|
||||
(members keep `player.control`; guests pass only via `playNext`)
|
||||
- new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }`
|
||||
- `/next` → `{ capability: "player.control", guestFlag: "skip" }`
|
||||
- `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }`
|
||||
- `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }`
|
||||
- `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }`
|
||||
- everything else stays `authorize({ capability })` (no guest flag) → guest-denied.
|
||||
- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a
|
||||
`mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the
|
||||
inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing
|
||||
destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided
|
||||
in the plan.
|
||||
- **Close ungated reads against guests:** `GET /api/bot/settings` and
|
||||
`GET /api/music/quality` currently have no guard, so a guest could read config. Add a
|
||||
small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This
|
||||
**does not change member/admin behavior** — members keep their current read access; only
|
||||
guests are newly denied. (Deliberately not a new member capability, to avoid touching
|
||||
member semantics.)
|
||||
|
||||
### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`)
|
||||
- Guests authenticate over the WS upgrade unchanged (session cookie).
|
||||
- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps
|
||||
`ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In
|
||||
`setupWebSocket`, when sending `init` and broadcasting `stateChange` /
|
||||
`botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests
|
||||
with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are
|
||||
unchanged (they resolve to `"all"` or their existing member scope — to avoid changing
|
||||
member behavior, filtering may be applied **only when the client is a guest**; decided
|
||||
in the plan).
|
||||
|
||||
### Settings write (`POST /api/bot/settings`)
|
||||
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
|
||||
and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling
|
||||
`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config.
|
||||
|
||||
## Frontend design
|
||||
|
||||
- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions`
|
||||
field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can`
|
||||
guest-aware where it maps cleanly, but UI gating for guest-specific actions uses
|
||||
`guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot
|
||||
scope and works for guests via the `bots` field.
|
||||
- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent
|
||||
"以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()`
|
||||
→ `POST /api/session/guest` → refresh → redirect to `?next` or home.
|
||||
- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from
|
||||
`/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior
|
||||
is unchanged.
|
||||
- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge
|
||||
branch; the bot selector already filters via `canControlBot`, so scoped guests only see
|
||||
allowed bots.
|
||||
- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player
|
||||
transport reduces to the guest's allowed actions.
|
||||
- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)`
|
||||
(e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/
|
||||
remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how
|
||||
`Queue.vue` already gates on `can('player.queue')` / `can('player.control')`.
|
||||
- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a
|
||||
master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope
|
||||
control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing
|
||||
member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with
|
||||
the `guestMode` block.
|
||||
|
||||
## Defaults, migration & backward-compat
|
||||
|
||||
- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade;
|
||||
existing installs see nothing until an admin opts in.
|
||||
- Migration adds the reserved `__guest__` user row idempotently (guarded like the
|
||||
existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it
|
||||
any `user_permissions` (guest authorization is config-driven, not row-driven).
|
||||
- `loadConfig` deep-merges `guestMode` so older config files gain the new block with
|
||||
defaults.
|
||||
- Member/admin flows, capabilities, and the backfill are untouched.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig`
|
||||
deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through
|
||||
`saveConfig`.
|
||||
- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"`
|
||||
vs scoped Set; `guest` permissions object passthrough.
|
||||
- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression
|
||||
parity with `requirePermission`); guest allowed only when the specific flag is true;
|
||||
guest with no flag on a route → 403; guest on settings reads → 403.
|
||||
- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently
|
||||
opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/
|
||||
`/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`.
|
||||
- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session
|
||||
when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode
|
||||
invalidates existing guest sessions (401); guest TTL shorter than member TTL.
|
||||
- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade
|
||||
unchanged for no cookie.
|
||||
- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests.
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
|
||||
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
|
||||
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
|
||||
(a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future).
|
||||
- No change to the `admin | member` capability semantics; guest is an additive,
|
||||
config-driven third principal.
|
||||
- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only**
|
||||
(the issue is explicitly about WebUI 鉴权).
|
||||
- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest
|
||||
scope applies to all guests).
|
||||
|
||||
## Key files touched
|
||||
|
||||
Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test),
|
||||
`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration),
|
||||
`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new,
|
||||
+test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads),
|
||||
`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`),
|
||||
`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings
|
||||
read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock),
|
||||
`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts`
|
||||
(guest disable→401), enforcement tests.
|
||||
|
||||
Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`,
|
||||
`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`,
|
||||
`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`,
|
||||
`web/src/components/Player.vue`, `web/src/views/Settings.vue`,
|
||||
`web/src/stores/player.ts`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# TeamSpeak chat-command permission control — design
|
||||
|
||||
**Origin:** User request — "给 ts 命令也加上权限控制" (give the TS chat commands permission control too, like the WebUI already has). Completes the unused `adminGroups` scaffold the original authors left behind.
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Scope
|
||||
|
||||
Add permission control to **TeamSpeak chat commands** (`!play`, `!add`, `!stop`, …). Today any client in a channel with the bot can run any command; only the WebUI path is permission-gated. This adds a **binary admin gate** keyed on the sender's **TS server groups**: a fixed set of "admin" commands may be restricted to members of configured admin server-groups, while all other commands stay public. Enforcement is **opt-in and backward-compatible** — it activates only once an admin lists their server-group ID(s).
|
||||
|
||||
The privileged server-groups are configured in `config.adminGroups` (already declared, currently unused) and become editable from the WebUI.
|
||||
|
||||
## Problem
|
||||
|
||||
`src/bot/commands.ts` already declares `PUBLIC_COMMANDS` / `ADMIN_COMMANDS` sets and an `isAdminCommand()` helper, and `src/bot/instance.ts:325` has the stub `// TODO: Check if invoker is in adminGroups` — but none of it gates anything. `config.adminGroups: number[]` (`src/data/config.ts:21,46`) is documented as a legacy placeholder and read nowhere. So chat commands are unauthenticated: anyone can `!stop`, `!clear`, `!remove`, move the bot, change volume/mode. The WebUI, by contrast, gates everything via `authorize()` at the HTTP layer.
|
||||
|
||||
`executeCommand` (`instance.ts:351`) is **shared** by the chat handler and the WebUI player router; the WebUI gates at the HTTP layer, so the chat gate must live in the **chat handler**, never inside `executeCommand` (else the already-gated WebUI would be double-gated).
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
1. **Binary admin gate**, not per-group capabilities and not a whole-bot allowlist. Reuses the existing `adminGroups` scaffold.
|
||||
2. **Admin command set (fixed, one source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. The set lives in one constant so reclassifying a command is a one-line change.
|
||||
3. **Default = open / opt-in (backward-compatible):** when `config.adminGroups` is empty (the default), there is **no enforcement** — admin commands stay open to everyone, exactly as today. Enforcement turns on only when `adminGroups` is non-empty.
|
||||
4. **Identity key = TS server groups**, matched against `adminGroups`.
|
||||
5. **Fail-closed on undeterminable groups:** if an admin command arrives, enforcement is on, and the sender's groups cannot be determined (even after a fallback lookup), **deny**.
|
||||
6. **Reply on deny:** the bot sends the sender a brief permission-denied message (silent denial is confusing; the bot already replies to commands).
|
||||
7. **Config surface:** `adminGroups` becomes editable from an admin-only WebUI Settings section, live-applied via the existing `/api/bot/settings` endpoint; `config.json` continues to work.
|
||||
|
||||
## Permission model
|
||||
|
||||
Tier definitions live in `src/bot/commands.ts` (repurpose the existing dead sets; the admin set is the source of truth):
|
||||
- **Admin commands:** `stop`, `clear`, `remove`, `move`, `vol`, `mode`.
|
||||
- **Public commands:** all others (`play`, `add`, `playnext`/`pn`, `skip`/`next`, `prev`, `pause`, `resume`, `now`, `queue`/`list`, `lyrics`, `vote`, `help`, `search`/`find`, `playlist`, `album`, `artist`, `fm`).
|
||||
|
||||
**Enforcement rule** — a command is **allowed** iff:
|
||||
1. it is a public command, **OR**
|
||||
2. `config.adminGroups` is empty (enforcement off), **OR**
|
||||
3. the sender's server groups ∩ `config.adminGroups` ≠ ∅.
|
||||
|
||||
Otherwise it is **denied** (no execution; a denial reply is sent).
|
||||
|
||||
Expressed as a pure, unit-testable helper (no TS/async dependency):
|
||||
```ts
|
||||
// returns true = allowed, false = denied
|
||||
function canRunCommand(
|
||||
commandName: string,
|
||||
invokerGroups: readonly (string | number)[],
|
||||
adminGroups: readonly number[]
|
||||
): boolean
|
||||
```
|
||||
- not an admin command → `true`.
|
||||
- admin command, `adminGroups.length === 0` → `true` (enforcement off).
|
||||
- admin command, non-empty `adminGroups` → `true` iff any `invokerGroups` value (normalized to number/string consistently) is in `adminGroups`, else `false`.
|
||||
|
||||
> Note: `invokerGroups` from TS are strings; `adminGroups` are numbers. Normalize both sides (compare as the same type) to avoid `"6" !== 6` bugs.
|
||||
|
||||
## Identity resolution
|
||||
|
||||
The TS library already delivers the sender's server groups on each chat event (`TextMessage.invokerGroups: string[]` in `@honeybbq/teamspeak-client`), but the wrapper type `TS3TextMessage` (`src/ts-protocol/client.ts:58-64`) and its mapping (`client.ts:205-214`) **drop** it.
|
||||
|
||||
Changes:
|
||||
1. Add `invokerGroups: string[]` to `TS3TextMessage` and populate it from `msg.invokerGroups` in the mapping.
|
||||
2. **Availability caveat:** `invokerGroups` is populated only when the sender's client is in the bot's local cache (typically same channel / in view). For a private message from an unseen client, it is `[]`.
|
||||
3. **Fallback lookup (only when needed):** in the gate, if the command is admin-gated **and** enforcement is on **and** `invokerGroups` is empty, perform a targeted lookup of the sender's groups keyed on `invokerId` (clid) — reuse the already-wrapped `getClientsInChannel()` (`client.ts:314-323`, whose `ClientInfo` carries `serverGroups`), or add a thin wrapper around the library's `getClientInfo(client, clid)` for a precise `clientinfo` query. This query is skipped entirely for public commands, when enforcement is off, and when the event already carried groups (the common "listener in the channel types `!stop`" case).
|
||||
4. **Fail-closed:** if after the fallback the groups are still unknown, deny the admin command.
|
||||
|
||||
## Enforcement seam
|
||||
|
||||
In `handleTextMessage` (`src/bot/instance.ts:317`), replace the dead stub at `instance.ts:325-327` with the real check, placed after `parseCommand` succeeds and **before** `executeCommand` (`instance.ts:335`):
|
||||
- compute `allowed` via `canRunCommand(parsed.name, msg.invokerGroups, this.config.adminGroups)`, performing the async fallback lookup only when the synchronous check is "deny due to empty groups on an admin command with enforcement on";
|
||||
- if denied → send the denial reply to `msg` (respecting its `targetMode`/sender) and return without executing;
|
||||
- if allowed → `executeCommand(parsed, msg)` as today.
|
||||
|
||||
`executeCommand` stays permission-agnostic, so the WebUI path is unaffected.
|
||||
|
||||
**Live config:** `BotInstance` already holds the shared `config` object by reference (passed through `BotInstanceOptions`); `POST /api/bot/settings` mutates that same object in place, so reading `this.config.adminGroups` in the gate reflects edits immediately — no restart, no re-wiring. (Implementation must confirm the instance reads `adminGroups` from the live `config` reference, not a copied-at-construction value.)
|
||||
|
||||
## Denied UX
|
||||
|
||||
The bot replies to the sender with a short bilingual-ish message, e.g. `⛔ 需要管理员权限(该命令仅限管理员服务器组)`, via the same reply mechanism the command handlers already use, honoring the message's `targetMode` (private vs channel). No execution occurs.
|
||||
|
||||
## Config surface
|
||||
|
||||
**Backend** (`src/web/api/bot.ts`): extend the existing settings endpoints (already admin-gated: `GET` behind `requireNotGuest`, `POST` behind `requirePermission("bot.manage")`):
|
||||
- `GET /api/bot/settings` → also return `adminGroups: number[]`.
|
||||
- `POST /api/bot/settings` → also accept `adminGroups`; validate it is an array of non-negative integers (filter/reject otherwise), assign to `config.adminGroups`, `saveConfig`. Reuses the in-place-mutation + `saveConfig` pattern already used for idle-timeout/auto-pause/guestMode, so it is live-applied.
|
||||
|
||||
**Frontend** (`web/src/views/Settings.vue`): a new admin-only section **"命令权限 / Command permissions"** (`v-if="session.isAdmin.value"`), mirroring the idle-timeout/guest-mode sections:
|
||||
- a text input for comma-separated server-group IDs (parsed to `number[]`, ignoring blanks/non-numbers), a Save button calling `POST /api/bot/settings`, hydrated by the existing `loadIdleTimeout()` GET;
|
||||
- hint: "仅这些组可运行 stop/clear/remove/move/vol/mode;留空 = 不限制(所有人可用)。如何查看服务器组 ID 见 README。"
|
||||
|
||||
**`config.json`**: `adminGroups` continues to work for file-based config.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`canRunCommand` unit tests** (`src/bot/commands.test.ts` or a new file): public command always allowed; admin command with empty `adminGroups` allowed; admin command with a matching group allowed; admin command with no matching group denied; string-vs-number normalization (`["6"]` matches `[6]`).
|
||||
- **Handler gate tests:** a denied admin command does NOT call `executeCommand` and triggers a denial reply; an allowed admin command (matching group) and any public command DO call `executeCommand`. (Use a fake `msg` + a `config` with `adminGroups` set; stub the reply + `executeCommand`.)
|
||||
- **Fallback path:** admin command with empty `invokerGroups` + enforcement on triggers the group lookup; if the lookup yields a matching group → allowed; if it yields nothing → denied (fail-closed).
|
||||
- **Settings round-trip** (`src/web/api/bot.test.ts`): `POST /api/bot/settings` persists a validated `adminGroups`; `GET` returns it; invalid values (non-array, negative, non-integer) are rejected/filtered.
|
||||
- **Frontend:** `vue-tsc --noEmit` clean.
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
|
||||
- No per-group capability map and no whole-bot allowlist (binary admin gate only).
|
||||
- No per-command customization of the admin/public split in the UI (the set is a code constant; reclassifying is a one-line edit).
|
||||
- No server-group picker UI (admin types IDs; a picker that lists the bot's visible groups is a possible future enhancement).
|
||||
- No new chat *management* commands.
|
||||
- No change to the WebUI authorization model or `executeCommand` semantics.
|
||||
|
||||
## Key files touched
|
||||
|
||||
Backend: `src/bot/commands.ts` (admin-set constant + `canRunCommand` helper, repurpose the dead sets; +test), `src/bot/instance.ts` (gate in `handleTextMessage`, denial reply, live `adminGroups`), `src/ts-protocol/client.ts` (surface `invokerGroups` on `TS3TextMessage`; possibly a `getClientInfo` wrapper for the fallback), `src/web/api/bot.ts` (settings read/write `adminGroups`; +test). Possibly `src/data/config.ts` (no schema change; `adminGroups` already exists).
|
||||
|
||||
Frontend: `web/src/views/Settings.vue` (admin-only 命令权限 section).
|
||||
|
||||
Docs: `README.md` (document the feature + how to find TS server-group IDs).
|
||||
@@ -0,0 +1,202 @@
|
||||
# Spotify audio source (optional, hybrid librespot) — design spec
|
||||
|
||||
- **Issue:** [#112 — Support for Spotify audio source](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/112)
|
||||
- **Date:** 2026-07-01
|
||||
- **Status:** Approved (design) — pending implementation plan
|
||||
- **Chosen approach:** Hybrid — real Spotify streaming via a librespot-family sidecar, with **go-librespot on Linux/Docker** and **Rust librespot on Windows**, behind one backend interface.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Add `spotify` as a new **optional** `MusicProvider`. Metadata (search / track / album / playlist) is read from the official **Spotify Web API**. Audio is the *real* Spotify stream, produced by a librespot-family sidecar and piped through ffmpeg into the bot's existing voice path.
|
||||
|
||||
The feature is **disabled by default**, opt-in, and **requires the user's own Spotify Premium account**. librespot is an unofficial, reverse-engineered client and using it **violates Spotify's Terms of Service** (account-ban risk). This is surfaced to the user as an explicit experimental warning, and no credentials are ever bundled.
|
||||
|
||||
If the feature is disabled, unauthenticated, or the sidecar binary is missing, the provider disables itself gracefully — exactly like `YouTubeProvider` when `yt-dlp` is absent (empty results, greyed-out in the UI, no crash).
|
||||
|
||||
## 2. Goals / Non-goals
|
||||
|
||||
**Goals**
|
||||
- First-class `spotify` source: search, play, playlist/album import, lyrics best-effort, login status.
|
||||
- Real Spotify audio (Premium), not a YouTube match.
|
||||
- Cross-platform: works on the project's three deployments — native Windows one-click, Linux systemd, Docker.
|
||||
- Strictly optional and safe-by-default; zero impact when off.
|
||||
- Mixed queues keep working (Spotify tracks interleaved with netease/qq/etc.).
|
||||
|
||||
**Non-goals**
|
||||
- No free-tier audio (Premium is mandatory for librespot streaming).
|
||||
- No bundled Spotify credentials or shared Developer app.
|
||||
- No replacement of, or change to, existing sources' behavior.
|
||||
- No CI e2e against live Spotify (requires Premium; manual only).
|
||||
|
||||
## 3. Backend selection
|
||||
|
||||
`spotify.backend: "auto" | "go-librespot" | "librespot"` (default `auto`).
|
||||
|
||||
| Platform | `auto` resolves to | Why |
|
||||
|---|---|---|
|
||||
| Windows | `librespot` (Rust) | go-librespot has **no** Windows binary and its FIFO capture is POSIX-only |
|
||||
| Linux / Docker | `go-librespot` (fallback `librespot`) | clean REST play-by-URI + prebuilt Linux binary |
|
||||
| macOS | `librespot` (or `go-librespot` if built) | no go-librespot macOS binary published |
|
||||
|
||||
The split is **platform-determined**, not a per-run user toggle (a user may still force one via config if they have the binary).
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 The seam — `SpotifyAudioBackend`
|
||||
|
||||
New file `src/music/spotify/backend.ts`:
|
||||
|
||||
```ts
|
||||
export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; }
|
||||
|
||||
export interface SpotifyAudioBackend {
|
||||
start(): Promise<void>; // launch sidecar + resampling ffmpeg
|
||||
stop(): void; // tear everything down
|
||||
isReady(): boolean; // device online & streamable
|
||||
playTrack(uri: string): Promise<void>; // begin ONE track (spotify:track:...)
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
seek(ms: number): Promise<void>;
|
||||
getPcmStream(): Readable; // 48kHz s16le stereo (post-ffmpeg)
|
||||
getPosition(): number; // ms into current track
|
||||
on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void;
|
||||
on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void;
|
||||
on(event: "ready" | "error", cb: (arg?: unknown) => void): void;
|
||||
}
|
||||
```
|
||||
|
||||
Both implementations emit the **same** 48 kHz s16le stereo PCM and the **same** events, so everything above the seam (provider, controller, player, UI) is backend-agnostic.
|
||||
|
||||
### 4.2 `GoLibrespotBackend` (Linux/Docker) — `src/music/spotify/go-librespot.ts`
|
||||
|
||||
- Writes a `config.yml` with `server: { enabled: true, address: 'localhost', port: <p> }`, `audio_backend: 'pipe'`, `audio_output_pipe: '<fifo>'`, `audio_output_pipe_format: 's16le'`, `bitrate: 320`, credentials block.
|
||||
- `start()`: `mkfifo <fifo>` → **spawn the FIFO-reading ffmpeg first** → then spawn go-librespot. (FIFO open ordering is mandatory: go-librespot opens the write end with `O_WRONLY|O_NONBLOCK` and errors `ENXIO` if no reader exists yet.) ffmpeg: `-f s16le -ar 44100 -ac 2 -i <fifo> -f s16le -ar 48000 -ac 2 -`. ffmpeg stdout = `getPcmStream()`.
|
||||
- Control (REST): `POST /player/play {uri}`, `POST /player/pause`, `POST /player/resume`, `POST /player/seek {position}`. `GET /status` for position.
|
||||
- Events (WebSocket `/events`): `metadata` → `metadata`; `not_playing` → `trackEnded` (track boundaries can NOT be read from the FIFO — it is gapless/continuous and never EOFs between tracks).
|
||||
- Metadata token: go-librespot exposes `POST /token` (first-party session token) and a `/web-api/<path>` proxy to `api.spotify.com` — so on this backend a separate Spotify Developer app is **optional**.
|
||||
- Recovery: on reader death go-librespot's write returns `EPIPE` and closes the pipe output; backend restarts the ffmpeg reader + reactivates the device.
|
||||
|
||||
### 4.3 `RustLibrespotBackend` (Windows / cross-platform) — `src/music/spotify/rust-librespot.ts`
|
||||
|
||||
Rust librespot is a **passive Spotify Connect receiver**; the bot acts as the **Connect controller** via the Spotify Web API.
|
||||
|
||||
- `start()`: spawn `librespot --backend pipe --name "<deviceName>" --bitrate 320 --cache <dir> [--access-token <tok> | --enable-oauth]`. With **no `--device`**, librespot writes raw PCM (**s16le, 44100 Hz, stereo**) to **stdout** (cross-platform; verified against `pipe.rs` — `None => Box::new(io::stdout())`). Pipe stdout → ffmpeg `-f s16le -ar 44100 -ac 2 -i pipe:0 -f s16le -ar 48000 -ac 2 -` → `getPcmStream()`. Do **not** pass `--passthrough` (that emits raw Ogg, not PCM).
|
||||
- `playTrack(uri)`: `GET /v1/me/player/devices` → find our `device_id` by `deviceName` → `PUT /v1/me/player/play?device_id={id}` body `{uris:[uri]}`. `pause/resume/seek` → `PUT /v1/me/player/{pause,play,seek}`.
|
||||
- `trackEnded`: poll `GET /v1/me/player` (is_playing → false / `item` changed / `progress_ms ≈ duration_ms`) plus optional `--onevent` hook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command `202/404` flakiness.
|
||||
- Requires a **user OAuth token** with scopes `streaming user-read-playback-state user-modify-playback-state user-read-currently-playing` (+ `playlist-read-private playlist-read-collaborative` for user playlists).
|
||||
|
||||
## 5. Shared subsystems
|
||||
|
||||
### 5.1 Metadata — `src/music/spotify/webapi.ts`
|
||||
|
||||
Thin `axios` client mapping Spotify catalog objects → the bot's `Song` / `Playlist` / `Album`:
|
||||
- `GET /v1/search?type=track,album,playlist&q=…`
|
||||
- `GET /v1/tracks/{id}`, `GET /v1/albums/{id}/tracks`, `GET /v1/playlists/{id}/tracks`
|
||||
|
||||
All confirmed still available with a normal token **after** Spotify's 2024-11-27 cut (that cut removed related-artists, recommendations, audio-features/analysis, featured/category playlists, and `preview_url` — none of which we use). Token source is pluggable: user Developer-app token (primary) or go-librespot `/web-api` proxy (go-librespot path). Handle `429 Retry-After` (rolling 30 s window; dev-mode quota).
|
||||
|
||||
### 5.2 Auth — `src/music/spotify/auth.ts`
|
||||
|
||||
One web-UI **Authorization-Code + PKCE** login.
|
||||
- **Primary:** the user registers their own Spotify Developer app (Client ID [+ Secret] + redirect URI) — reliable, ToS-cleaner. The resulting access token drives metadata + Web-API control; the refresh token is persisted (credential store); access tokens (~1 h) auto-refresh. The same token bootstraps librespot via `--access-token`, after which librespot caches reusable credentials — **one user-facing login**.
|
||||
- **Fallbacks:** librespot `--enable-oauth` (built-in client `65b708073fc0480ea92a077233ca87bd`, redirect `http://127.0.0.1:8898/login`) or go-librespot interactive login (`http://127.0.0.1:36842/login?code=…`) as a separate one-time step; go-librespot `/web-api` proxy when no Developer app is provided.
|
||||
- Username/password is **dead** (removed by Spotify in 2024); do not implement it.
|
||||
|
||||
### 5.3 Provider — `src/music/spotify/provider.ts`
|
||||
|
||||
`SpotifyProvider implements MusicProvider` with `platform: "spotify"`:
|
||||
- `search`, `getSongDetail`, `getPlaylistSongs`, `getAlbumSongs`, `getLyrics` (best-effort/empty), `getRecommendPlaylists` → Web API.
|
||||
- `getAuthStatus()` reports login state **and** backend/binary availability (drives greying-out in UI, like YouTube).
|
||||
- `getSongUrl(id)` returns a **sentinel** (`{ url: "spotify:track:<id>" }`) — actual playback is via the backend/controller, not a URL. `instance.ts` recognizes the sentinel and routes to the `SpotifyController`.
|
||||
- QR-code login methods are no-ops; Spotify uses the OAuth card instead.
|
||||
|
||||
## 6. Queue / player / instance integration
|
||||
|
||||
- **`SpotifyController`** (`src/music/spotify/controller.ts`, one per bot): owns the chosen backend and the Web-API/auth clients; exposes `playTrack/pause/resume/seek/stop` and forwards `trackEnded`/`metadata`.
|
||||
- **`AudioPlayer` — new external-PCM mode:** add `playPcmStream(readable, { onExternalEnd })` that feeds the existing `pcmBuffer` → 20 ms frame loop → encoder → `frame` path **without spawning a url-ffmpeg**. For Spotify, `trackEnd` is driven by the backend's `trackEnded` event (the librespot→ffmpeg pipeline is long-lived and does not exit per song). `pause/resume/seek` on a Spotify song are routed to the backend by `instance.ts` (and gate frame emission locally for crisp UI state).
|
||||
- **`instance.ts`:** `getProviderFor("spotify")`, a `-s` command flag in `getProvider(flags)`. When a dequeued `song.platform === "spotify"`: ensure the controller/backend is started + device active, `playTrack(uri)`, attach the player to the backend PCM. On `trackEnded` → advance the queue. When a **non-Spotify** song is next, **pause the sidecar** (so it doesn't buffer ahead) and use the normal `player.play(url)` path. This preserves one-track-at-a-time on-demand playback and mixed-source queues.
|
||||
- Real-time pacing is guaranteed by the voice consumer: TS voice pulls 20 ms frames at real time → the player reads PCM at real time → ffmpeg's read of the sidecar stalls → sidecar backpressure pauses decode. (This is why go-librespot's pull model avoids the classic librespot "plays too fast / skips" bug; Rust librespot to stdout is likewise paced by our reads.)
|
||||
|
||||
## 7. Config, opt-in & safety
|
||||
|
||||
`BotConfig.spotify` (all default-off), added to `getDefaultConfig()` and sanitized in `loadConfig()`:
|
||||
|
||||
```ts
|
||||
spotify: {
|
||||
enabled: false,
|
||||
backend: "auto", // "auto" | "go-librespot" | "librespot"
|
||||
clientId: "", // user's Developer app (optional on go-librespot path)
|
||||
clientSecret: "", // optional — PKCE needs none; only for confidential/client-credentials flows
|
||||
deviceName: "TSMusicBot",
|
||||
bitrate: 320, // 96 | 160 | 320
|
||||
}
|
||||
```
|
||||
|
||||
Inert unless `enabled` **and** logged-in **and** a resolvable binary. First-run/settings shows the experimental + ToS + Premium + own-credentials warning. Never store or transmit shared secrets.
|
||||
|
||||
## 8. Web UI
|
||||
|
||||
- Add `spotify` to `platform` and `Source` unions (`web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`).
|
||||
- `SourceTabs.vue`: add "Spotify" tab; `SongCard.vue`: green **#1DB954** badge; `variables.scss`: `--brand-spotify` tokens.
|
||||
- `stores/player.ts`: extend `authStatus` / `recommendPlaylists` / `dailySongs` / `userPlaylists` maps + the auth/recommend fetch fan-out.
|
||||
- Settings: a **Spotify login card distinct from the QR cards** — "Connect Spotify" OAuth button, optional Client ID/Secret fields, backend/binary status indicator, and the risk disclaimer.
|
||||
|
||||
## 9. Binary / dependency resolution — `src/music/spotify/binary.ts`
|
||||
|
||||
Mirror `findYtDlp()`: resolve `go-librespot` / `librespot(.exe)` from `bin/` then PATH; positive availability cached, negative retried (install-while-running).
|
||||
- **No prebuilt Rust librespot exists** (source-only: `cargo install librespot`, distro/`scoop`/`choco`, or a binary dropped in `bin/`).
|
||||
- **go-librespot** ships **Linux-only** assets (`go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz`, ~6 MB, at `github.com/devgianlu/go-librespot/releases`). Docker build downloads the correct Linux asset; optional runtime auto-download (like yt-dlp). go-librespot is **GPL-3.0** → sidecar = mere aggregation (does not infect the Node code); ship its license text + a source offer.
|
||||
- Unresolved binary → source disabled with an actionable "install X / see docs" message.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
Vitest, mocking child processes and HTTP:
|
||||
- Web-API response → `Song/Playlist/Album` mapping.
|
||||
- `chooseBackend()` per platform/config.
|
||||
- Auth: PKCE challenge, token refresh, credential persistence.
|
||||
- Config load/sanitize (defaults, hand-edited/corrupt input).
|
||||
- `SpotifyProvider` methods (mock webapi).
|
||||
- Player external-PCM path: feed a fake `Readable` → assert `frame` emission + `trackEnd` on external end.
|
||||
- go-librespot `/events` → `trackEnded` translation; Rust-backend Web-API poll → `trackEnded`.
|
||||
- e2e against live Spotify is **manual & documented** (Premium required), not CI.
|
||||
|
||||
## 11. Files touched
|
||||
|
||||
**New:** `src/music/spotify/{provider,backend,go-librespot,rust-librespot,webapi,auth,controller,binary}.ts` (+ `.test.ts`).
|
||||
**Edited (backend):** `src/music/provider.ts` (union: `Song`/`Playlist`/`Album`/`MusicProvider`), `src/index.ts`, `src/bot/manager.ts`, `src/bot/instance.ts` (router + `-s` flag + spotify routing), `src/audio/player.ts` (external-PCM mode), `src/data/config.ts`, `src/music/auth.ts` (credential store union), `src/web/api/auth.ts`, `src/web/api/music.ts` (routers + search aggregation), `src/web/server.ts`, `src/data/database.ts` (platform).
|
||||
**Edited (frontend):** `web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`, `web/src/components/SourceTabs.vue`, `web/src/components/SongCard.vue`, `web/src/styles/variables.scss`.
|
||||
**Docs:** `README.md` (Spotify section + warnings + install notes).
|
||||
|
||||
## 12. Staged rollout
|
||||
|
||||
1. **Metadata + provider + config + UI plumbing** (no audio): `spotify` source searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar.
|
||||
2. **go-librespot backend (Linux/Docker):** real playback on Linux; REST + FIFO + WebSocket.
|
||||
3. **Rust librespot backend (Windows):** stdout PCM + Web-API Connect control.
|
||||
4. **Polish:** recovery/watchdog, docs, binary auto-download, README.
|
||||
|
||||
## 13. Risks & open questions
|
||||
|
||||
- **Rust librespot control is the top risk** — Connect device visibility/latency, poll-based track-end. Mitigation: retry/backoff + watchdog; degrade to "skipped" on repeated failure.
|
||||
- **One-OAuth-token bootstraps librespot** (`--access-token` from the user's own app client) is *plausible but unverified* — may fall back to two one-time logins. Verify with a spike in stage 3.
|
||||
- **Windows go-librespot is impossible** (FIFO) → Windows always uses Rust librespot.
|
||||
- **Token scope from librespot's built-in client** may not include `user-modify-playback-state`; if so, a user Developer app is required for the Rust/Windows control path.
|
||||
- Large surface area → staged rollout above; each stage independently shippable.
|
||||
|
||||
## Appendix A — Verified technical facts (with sources)
|
||||
|
||||
go-librespot API (`devgianlu/go-librespot`, v0.7.x):
|
||||
- REST: `POST /player/{play,pause,resume,playpause,stop,next,prev,seek,volume,add_to_queue}`, `GET /status`, `GET /` (`{playback_ready}`), `POST /token`, `GET|POST /web-api/<path>`. `POST /player/play` body `{uri, skip_to_uri?, paused?}`. Server enabled only when `server.enabled: true`.
|
||||
- WebSocket `/events` envelopes `{type, data}`; types include `metadata, will_play, playing, paused, not_playing, stopped, seek, volume, active, inactive`. Track-end = `not_playing`.
|
||||
- Pipe backend: continuous raw PCM, no header, 44100 Hz stereo, format `s16le|s32le|f32le`; FIFO opened once; POSIX-only. Backpressure real (blocks when reader stalls).
|
||||
- Sources: `github.com/devgianlu/go-librespot` (README, `api-spec.yml`, `daemon/api_server.go`, `output/driver-pipe.go`).
|
||||
|
||||
Rust librespot (`librespot-org/librespot`, v0.8.0, MIT):
|
||||
- `--backend pipe` with no `--device` → raw PCM to **stdout**; default S16 / 44100 / stereo; `--format` for higher bit depth; `--passthrough` = raw Ogg (do not use). Passive Connect receiver — no play-by-URI; control via Web API Connect. `--onevent` = read-only hook. No prebuilt binaries.
|
||||
- Sources: `github.com/librespot-org/librespot` wiki (Audio-Backends, Options), `librespot_playback/audio_backend/pipe.rs`, `docs/authentication.md`.
|
||||
|
||||
Spotify auth & Web API:
|
||||
- Username/password removed (2024); use OAuth (Auth-Code+PKCE) or Zeroconf. Premium required for librespot audio.
|
||||
- Client-Credentials token authorizes `/v1/search` + public track/album/playlist GETs; 2024-11-27 cut did **not** touch these. Token endpoint `POST https://accounts.spotify.com/api/token` (`grant_type=client_credentials`, Basic base64(id:secret), `expires_in=3600`, no refresh). 429 on a rolling 30 s window.
|
||||
- Sources: `developer.spotify.com` (Web API docs; 2024-11-27 blog), `librespot` `docs/authentication.md`.
|
||||
@@ -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).
|
||||
Generated
+621
-415
File diff suppressed because it is too large.
Load diff
+19
-4
@@ -15,30 +15,45 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@honeybbq/teamspeak-client": "^0.1.0",
|
||||
"@honeybbq/teamspeak-client": "^0.2.1",
|
||||
"@koa/router": "^15.4.0",
|
||||
"@sansenjian/qq-music-api": "^2.2.9",
|
||||
"@sansenjian/qq-music-api": "~2.4.0",
|
||||
"axios": "^1.14.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"chalk": "^5.6.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"koa": "^3.2.0",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"NeteaseCloudMusicApi": "^4.30.0",
|
||||
"NeteaseCloudMusicApi": "~4.32.0",
|
||||
"pino": "^10.3.1",
|
||||
"ts3-nodejs-library": "^3.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.20.0"
|
||||
"ws": "^8.20.0",
|
||||
"yt-dlp-wrap": "^2.3.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.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
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -25,30 +25,27 @@ RUN cd web && npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Install production dependencies (with native addons compiled) in the builder
|
||||
# so we don't need build tools in the production image.
|
||||
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
||||
|
||||
# --- Stage 2: Production image ---
|
||||
FROM node:20-slim
|
||||
|
||||
# Install only runtime native build tools (needed for native module rebuild)
|
||||
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
||||
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
||||
# System-installed FFmpeg is always compatible with the container runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ && \
|
||||
ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built output
|
||||
# Copy built output and pre-compiled production node_modules from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/web/dist ./web/dist
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Install production dependencies (includes ffmpeg-static, opus, sqlite3)
|
||||
# These need to compile native addons inside the container
|
||||
RUN npm ci --production && npm cache clean --force
|
||||
|
||||
# Remove build tools to reduce image size
|
||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# FFmpeg is bundled via ffmpeg-static — no system ffmpeg needed
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Data directory for database, cookies, logs
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# TSMusicBot — Docker Compose
|
||||
# 一键部署:docker-compose up -d
|
||||
# 一键部署:docker-compose pull && docker-compose up -d
|
||||
#
|
||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
|
||||
# 无需安装任何额外软件
|
||||
# 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
|
||||
# 无需本地编译,无需 Node.js / 构建工具链。
|
||||
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
|
||||
#
|
||||
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
|
||||
# 如需本地构建(开发或 fork),见底部注释。
|
||||
|
||||
services:
|
||||
tsmusicbot:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: scripts/docker/Dockerfile
|
||||
image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
|
||||
container_name: tsmusicbot
|
||||
# Use host network so the bot can reach TS3 server on LAN
|
||||
# If your TS3 server is on the same machine, this is required
|
||||
@@ -27,3 +29,12 @@ services:
|
||||
volumes:
|
||||
tsmusicbot-data:
|
||||
driver: local
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 本地构建(仅开发 / fork 场景使用):
|
||||
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
|
||||
#
|
||||
# build:
|
||||
# context: ../..
|
||||
# dockerfile: scripts/docker/Dockerfile
|
||||
# ------------------------------------------------------------------
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
|
||||
* Called by setup.bat after npm install --ignore-scripts.
|
||||
*
|
||||
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { createGunzip } from "node:zlib";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { get } from "node:https";
|
||||
import { Readable } from "node:stream";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
|
||||
const PLATFORM = process.platform;
|
||||
const ARCH = process.arch;
|
||||
const NODE_ABI = process.versions.modules;
|
||||
|
||||
function download(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = get(url, { timeout: 120000 }, (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
||||
});
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(` [binary] ${msg}`);
|
||||
}
|
||||
|
||||
function isValidSize(filePath, minBytes) {
|
||||
try { return statSync(filePath).size >= minBytes; } catch { return false; }
|
||||
}
|
||||
|
||||
async function downloadFfmpeg() {
|
||||
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
|
||||
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
const ffDest = join(ffDir, ffName);
|
||||
|
||||
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
|
||||
if (existsSync(ffDest)) {
|
||||
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
|
||||
log("ffmpeg already exists, skipping");
|
||||
return true;
|
||||
}
|
||||
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
|
||||
}
|
||||
|
||||
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
|
||||
log("Downloading ffmpeg...");
|
||||
const buf = await download(url);
|
||||
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
|
||||
try { execSync(`chmod +x "${ffDest}"`); } catch {}
|
||||
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
|
||||
log(`ffmpeg OK (${size} MB)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadOpus() {
|
||||
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
|
||||
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
|
||||
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
|
||||
|
||||
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
|
||||
if (existsSync(opusDest)) {
|
||||
if (isValidSize(opusDest, 100 * 1024)) {
|
||||
log("@discordjs/opus already exists, skipping");
|
||||
return true;
|
||||
}
|
||||
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
|
||||
}
|
||||
|
||||
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
|
||||
log("Downloading @discordjs/opus...");
|
||||
try {
|
||||
const buf = await download(url);
|
||||
mkdirSync(dirname(opusDest), { recursive: true });
|
||||
const require = createRequire(import.meta.url);
|
||||
const tar = require("tar");
|
||||
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
|
||||
writeFileSync(tmpFile, buf);
|
||||
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
|
||||
log("@discordjs/opus OK");
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`CDN download failed (${err.message}), trying to build from source...`);
|
||||
try {
|
||||
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
|
||||
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
|
||||
log("@discordjs/opus built from source OK");
|
||||
return true;
|
||||
}
|
||||
log("Source build completed but .node file not found");
|
||||
return false;
|
||||
} catch (buildErr) {
|
||||
log(`Source build failed: ${buildErr.message}`);
|
||||
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
|
||||
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBetterSqlite3() {
|
||||
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
|
||||
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
|
||||
|
||||
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
|
||||
if (existsSync(dest)) {
|
||||
if (isValidSize(dest, 500 * 1024)) {
|
||||
log("better-sqlite3 already exists, skipping");
|
||||
return true;
|
||||
}
|
||||
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
|
||||
}
|
||||
|
||||
const version = "12.8.0";
|
||||
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
|
||||
log("Downloading better-sqlite3...");
|
||||
const buf = await download(url);
|
||||
const require = createRequire(import.meta.url);
|
||||
const tar = require("tar");
|
||||
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
|
||||
writeFileSync(tmpFile, buf);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
await tar.extract({ cwd: pkgDir, file: tmpFile });
|
||||
if (existsSync(dest)) {
|
||||
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
|
||||
return true;
|
||||
}
|
||||
log("better-sqlite3 extracted but .node file not found at expected path");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
|
||||
if (results.some(Boolean)) {
|
||||
console.log(" [binary] All downloads complete");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` [binary] ERROR: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+41
-17
@@ -6,9 +6,19 @@ echo "║ TSMusicBot Installer ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Resolve script location → project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
INSTALL_DIR="/opt/tsmusicbot"
|
||||
SERVICE_NAME="tsmusicbot"
|
||||
|
||||
# Verify we're in a valid project directory
|
||||
if [ ! -f "$PROJECT_DIR/package.json" ]; then
|
||||
echo "Error: Cannot find package.json in $PROJECT_DIR"
|
||||
echo "Please run this script from the TSMusicBot project directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
@@ -18,44 +28,57 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/5] Installing system dependencies..."
|
||||
echo "[1/6] Installing system dependencies..."
|
||||
case $OS in
|
||||
ubuntu|debian)
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq curl ffmpeg
|
||||
sudo apt-get install -y -qq curl build-essential python3
|
||||
;;
|
||||
centos|rhel|fedora)
|
||||
sudo yum install -y curl ffmpeg
|
||||
sudo yum install -y curl gcc gcc-c++ make python3
|
||||
;;
|
||||
arch|manjaro)
|
||||
sudo pacman -S --noconfirm curl ffmpeg
|
||||
sudo pacman -S --noconfirm curl base-devel python
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
|
||||
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[2/5] Installing Node.js 20 LTS..."
|
||||
echo "[2/6] Installing Node.js 20 LTS..."
|
||||
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
|
||||
fi
|
||||
echo "Node.js $(node -v) installed"
|
||||
|
||||
echo "[3/5] Downloading TSMusicBot..."
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
if [ -d "$(pwd)/dist" ]; then
|
||||
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
|
||||
else
|
||||
echo "Please run this script from the TSMusicBot source directory after building."
|
||||
exit 1
|
||||
echo "[3/6] Installing dependencies..."
|
||||
cd "$PROJECT_DIR"
|
||||
npm install
|
||||
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
|
||||
(cd "$PROJECT_DIR/web" && npm install)
|
||||
fi
|
||||
|
||||
echo "[4/5] Installing npm dependencies..."
|
||||
cd "$INSTALL_DIR"
|
||||
sudo npm install --production
|
||||
echo "[4/6] Building project..."
|
||||
npm run build
|
||||
|
||||
echo "[5/5] Creating systemd service..."
|
||||
echo "[5/6] Copying to $INSTALL_DIR..."
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
|
||||
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
|
||||
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
|
||||
# Copy web frontend if built
|
||||
if [ -d "$PROJECT_DIR/web/dist" ]; then
|
||||
sudo mkdir -p "$INSTALL_DIR/web"
|
||||
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
|
||||
fi
|
||||
# Copy scripts for future use
|
||||
sudo mkdir -p "$INSTALL_DIR/scripts"
|
||||
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
|
||||
# Create data directory
|
||||
sudo mkdir -p "$INSTALL_DIR/data"
|
||||
|
||||
echo "[6/6] Creating systemd service..."
|
||||
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
|
||||
[Unit]
|
||||
Description=TSMusicBot - TeamSpeak Music Bot
|
||||
@@ -88,4 +111,5 @@ echo "║ Commands: ║"
|
||||
echo "║ systemctl status tsmusicbot ║"
|
||||
echo "║ systemctl restart tsmusicbot ║"
|
||||
echo "║ systemctl stop tsmusicbot ║"
|
||||
echo "║ journalctl -u tsmusicbot -f ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Open a real Chromium browser at y.qq.com, let the user log in via any
|
||||
method (password / QR / QQ connect), then extract the resulting cookie
|
||||
set and save it to data/cookies/qq.json. Also tests whether the cookie
|
||||
actually unlocks a known VIP track (Jay Chou 稻香) against the local
|
||||
QQ Music API before declaring success.
|
||||
|
||||
Usage:
|
||||
"C:/Users/saopig1/miniforge3/python.exe" scripts/qq_browser_login.py
|
||||
|
||||
Steps:
|
||||
1. A visible Chromium window opens at y.qq.com/n/ryqq/player
|
||||
2. Click the login button in the top right and log in with your
|
||||
real QQ Music account (the one that has VIP)
|
||||
3. The script POLLS cookies in the background and auto-detects
|
||||
successful login by watching for the `uin` cookie to appear
|
||||
4. Once detected, cookies are captured, tested against
|
||||
/getMusicPlay for 稻香, and saved on success
|
||||
5. If VIP still fails, cookies are NOT saved — your existing bot
|
||||
cookie stays untouched
|
||||
|
||||
No terminal input required — the script exits on its own when login
|
||||
is detected (or after the configured timeout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BOT_ROOT = Path(r"C:\Users\saopig1\Music\teamspeak music bot")
|
||||
COOKIE_FILE = BOT_ROOT / "data" / "cookies" / "qq.json"
|
||||
QQ_API = "http://localhost:3200"
|
||||
VIP_TEST_SONGMID = "003aAYrm3GE0Ac" # 稻香 周杰伦
|
||||
|
||||
# How long to wait for the user to finish logging in
|
||||
LOGIN_TIMEOUT_S = 300 # 5 minutes
|
||||
POLL_INTERVAL_S = 1.0
|
||||
# After detecting login, wait a bit for extra cookies (e.g. qqmusic_key)
|
||||
SETTLE_DELAY_S = 4.0
|
||||
|
||||
|
||||
def qq_cookies(ctx) -> list[dict]:
|
||||
wanted_suffixes = (".qq.com", "y.qq.com", ".music.qq.com")
|
||||
return [
|
||||
c for c in ctx.cookies()
|
||||
if any(c.get("domain", "").endswith(s) or c.get("domain", "") == s.lstrip(".")
|
||||
for s in wanted_suffixes)
|
||||
]
|
||||
|
||||
|
||||
def cookies_to_header(cookies: list[dict]) -> str:
|
||||
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
||||
|
||||
|
||||
def cookie_has_uin(cookies: list[dict]) -> str | None:
|
||||
for c in cookies:
|
||||
if c["name"] == "uin" and c["value"]:
|
||||
return c["value"]
|
||||
return None
|
||||
|
||||
|
||||
def test_vip_unlock(cookie_header: str) -> tuple[bool, dict]:
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{QQ_API}/getMusicPlay",
|
||||
params={"songmid": VIP_TEST_SONGMID, "cookie": cookie_header},
|
||||
timeout=10,
|
||||
proxies={"http": None, "https": None},
|
||||
)
|
||||
body = r.json()
|
||||
play = body.get("data", {}).get("playUrl", {}).get(VIP_TEST_SONGMID, {})
|
||||
url = play.get("url", "")
|
||||
return (
|
||||
bool(url),
|
||||
{
|
||||
"url_length": len(url),
|
||||
"url_prefix": url[:120] if url else "",
|
||||
"error": play.get("error", ""),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return False, {"error": f"request failed: {e}"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[setup] launching visible Chromium — look for the window on your desktop")
|
||||
print("[setup] goto https://y.qq.com/n/ryqq/player")
|
||||
print()
|
||||
print("action required:")
|
||||
print(" 1. Click the 登录 button (top-right) in the browser window")
|
||||
print(" 2. Log in with your VIP QQ Music account (QR / password / WeChat)")
|
||||
print(" 3. Do NOTHING in this terminal — the script detects login itself")
|
||||
print()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1280, "height": 820},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/132.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = ctx.new_page()
|
||||
try:
|
||||
page.goto("https://y.qq.com/n/ryqq/player", wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as e:
|
||||
print(f"[warn] initial navigation slow: {e}")
|
||||
|
||||
print(f"[wait] polling every {POLL_INTERVAL_S}s for login (timeout {LOGIN_TIMEOUT_S}s)")
|
||||
deadline = time.time() + LOGIN_TIMEOUT_S
|
||||
uin_detected: str | None = None
|
||||
last_report = 0.0
|
||||
while time.time() < deadline:
|
||||
cks = qq_cookies(ctx)
|
||||
uin = cookie_has_uin(cks)
|
||||
if uin:
|
||||
uin_detected = uin
|
||||
print(f"[detect] uin cookie appeared: {uin}")
|
||||
break
|
||||
now = time.time()
|
||||
if now - last_report >= 15:
|
||||
remaining = int(deadline - now)
|
||||
n = len(cks)
|
||||
print(f"[wait] still waiting... {n} qq.com cookies so far, {remaining}s left")
|
||||
last_report = now
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
|
||||
if not uin_detected:
|
||||
print("[abort] login not detected within timeout")
|
||||
browser.close()
|
||||
return 1
|
||||
|
||||
print(f"[settle] waiting {SETTLE_DELAY_S}s for session cookies to populate")
|
||||
time.sleep(SETTLE_DELAY_S)
|
||||
|
||||
cks = qq_cookies(ctx)
|
||||
cookie_header = cookies_to_header(cks)
|
||||
print(f"[capture] {len(cks)} cookies, {len(cookie_header)} char header")
|
||||
|
||||
qm_key = next((c["value"] for c in cks if c["name"] == "qqmusic_key"), "")
|
||||
qm_keyst = next((c["value"] for c in cks if c["name"] == "qm_keyst"), "")
|
||||
p_skey = next((c["value"] for c in cks if c["name"] == "p_skey"), "")
|
||||
print(f"[capture] qqmusic_key: {'present (' + qm_key[:20] + '...)' if qm_key else '(absent)'}")
|
||||
print(f"[capture] qm_keyst : {'present (' + qm_keyst[:20] + '...)' if qm_keyst else '(absent)'}")
|
||||
print(f"[capture] p_skey : {'present' if p_skey else '(absent)'}")
|
||||
|
||||
print("\n[test] calling /getMusicPlay for 稻香 with captured cookie...")
|
||||
unlocked, details = test_vip_unlock(cookie_header)
|
||||
print(f"[test] unlocked: {unlocked}")
|
||||
print(f"[test] details: {details}")
|
||||
|
||||
if not unlocked:
|
||||
print(
|
||||
"\n[result] VIP did NOT unlock even with browser-extracted cookies.\n"
|
||||
" Existing cookie file is UNTOUCHED.\n"
|
||||
" Diagnosis: the login flow is not the bottleneck — the\n"
|
||||
" account likely lacks entitlement for this specific track,\n"
|
||||
" OR QQ requires additional session setup (gateway handshake)\n"
|
||||
" beyond what's in the cookie itself.\n"
|
||||
)
|
||||
# Dump the full cookie set for inspection
|
||||
dump_path = BOT_ROOT / "data" / "cookies" / "qq.browser-capture.json"
|
||||
dump_path.write_text(
|
||||
json.dumps(
|
||||
{"cookie": cookie_header, "cookieList": cks, "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"[dump] full browser cookies written to {dump_path}")
|
||||
print(" (for side-by-side comparison with OAuth-derived cookies)")
|
||||
browser.close()
|
||||
return 2
|
||||
|
||||
print("\n[save] VIP unlocked. Writing cookie to bot...")
|
||||
COOKIE_FILE.write_text(
|
||||
json.dumps(
|
||||
{"cookie": cookie_header, "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"[save] wrote {COOKIE_FILE}")
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
"http://localhost:3000/api/auth/cookie",
|
||||
json={"platform": "qq", "cookie": cookie_header},
|
||||
timeout=5,
|
||||
proxies={"http": None, "https": None},
|
||||
)
|
||||
print(f"[notify] /api/auth/cookie POST: {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"[notify] failed to push cookie to bot: {e}")
|
||||
print(" Restart the bot to pick up the new cookie from disk.")
|
||||
|
||||
print("\n[done] VIP should now work through the bot. Try playing 稻香!")
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Open a visible browser at y.qq.com so the user can manually verify
|
||||
whether their VIP account can play 稻香 (Jay Chou) in the real QQ Music
|
||||
web player.
|
||||
|
||||
If the browser plays the song → entitlement exists and our 104003 is a
|
||||
request-signing issue.
|
||||
If the browser refuses / shows a VIP modal / silently fails → the
|
||||
account doesn't have entitlement OR QQ's web player hits the same wall.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Force line-buffered stdout so logs actually reach the output file
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
|
||||
START_URL = "https://y.qq.com/n/ryqq/player"
|
||||
SONG_URL = "https://y.qq.com/n/ryqq/songDetail/003aAYrm3GE0Ac"
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
log("[setup] launching visible Chromium")
|
||||
log(f"[setup] start URL: {START_URL}")
|
||||
log(f"[setup] song URL: {SONG_URL}")
|
||||
log("")
|
||||
log("action required:")
|
||||
log(" 1. The browser opens at the player page")
|
||||
log(" 2. Make sure your VIP account is logged in (top-right avatar)")
|
||||
log(" — if not, log in now, the script will wait")
|
||||
log(" 3. Once logged in, the browser will auto-navigate to 稻香")
|
||||
log(" 4. Click the PLAY button and report what happens:")
|
||||
log(" (a) song plays → account has entitlement, issue is request signing")
|
||||
log(" (b) VIP modal → account needs higher tier / digital album purchase")
|
||||
log(" (c) silent failure → QQ web player has same 104003 wall")
|
||||
log("")
|
||||
log("[wait] browser stays open for 5 minutes")
|
||||
log("")
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
except Exception as e:
|
||||
log(f"[fatal] failed to launch Chromium: {e}")
|
||||
return 1
|
||||
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1400, "height": 900},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/132.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
# Log every navigation so we can see if pages fail
|
||||
page.on("framenavigated", lambda f: log(f"[nav] {f.url[:120]}") if f == page.main_frame else None)
|
||||
page.on("pageerror", lambda e: log(f"[js-error] {str(e)[:200]}"))
|
||||
|
||||
# Step 1: open the player page (known working)
|
||||
log(f"[goto] {START_URL}")
|
||||
try:
|
||||
page.goto(START_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
log(f"[ok] loaded: {page.url}")
|
||||
except Exception as e:
|
||||
log(f"[warn] initial goto failed: {e}")
|
||||
log("[warn] browser stays open, try manual navigation")
|
||||
|
||||
# Wait briefly for auth state to settle
|
||||
time.sleep(3)
|
||||
|
||||
# Check if the user is logged in via the uin cookie
|
||||
cookies = ctx.cookies()
|
||||
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
|
||||
if uin:
|
||||
log(f"[auth] logged in as uin={uin}")
|
||||
else:
|
||||
log("[auth] not logged in yet — please log in via the top-right avatar")
|
||||
log("[auth] waiting up to 2 minutes for login...")
|
||||
end = time.time() + 120
|
||||
while time.time() < end:
|
||||
time.sleep(1)
|
||||
cookies = ctx.cookies()
|
||||
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
|
||||
if uin:
|
||||
log(f"[auth] detected login: uin={uin}")
|
||||
break
|
||||
if not uin:
|
||||
log("[abort] no login detected within 2 minutes")
|
||||
time.sleep(30) # keep browser visible
|
||||
browser.close()
|
||||
return 2
|
||||
|
||||
# Step 2: navigate to the song page
|
||||
log(f"[goto] {SONG_URL}")
|
||||
try:
|
||||
page.goto(SONG_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
log(f"[ok] loaded: {page.url}")
|
||||
except Exception as e:
|
||||
log(f"[warn] song navigation failed: {e}")
|
||||
log(f"[info] current page: {page.url}")
|
||||
|
||||
log("")
|
||||
log("===========================================================")
|
||||
log("browser is open on the song page — click PLAY and observe.")
|
||||
log("keeping browser open for 5 more minutes")
|
||||
log("===========================================================")
|
||||
|
||||
time.sleep(300)
|
||||
browser.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto("http://localhost:3000")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png")
|
||||
rect = page.locator(".bot-selector-btn").bounding_box()
|
||||
print("bot-selector-btn bbox:", rect)
|
||||
browser.close()
|
||||
+294
-91
@@ -1,91 +1,294 @@
|
||||
@echo off
|
||||
title TSMusicBot Setup
|
||||
echo ============================================
|
||||
echo TSMusicBot - First-Time Setup (Windows)
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
:: Resolve project root (one level up from scripts/)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
:: ---- Step 1: Check / install Node.js ----
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Node.js not found. Attempting automatic installation...
|
||||
echo.
|
||||
|
||||
:: Try winget first (available on Windows 10 1709+ and Windows 11)
|
||||
where winget >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo Installing Node.js via winget...
|
||||
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
|
||||
if %errorlevel% neq 0 (
|
||||
echo winget installation failed. Please install Node.js manually from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
:: Refresh PATH so node is available in this session
|
||||
call refreshenv >nul 2>&1
|
||||
:: If refreshenv is not available, ask user to restart
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Node.js was installed but is not yet available in this terminal.
|
||||
echo Please close this window and run setup.bat again.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
) else (
|
||||
echo winget is not available on this system.
|
||||
echo Please install Node.js 20 LTS manually from https://nodejs.org
|
||||
echo After installing, close this window and run setup.bat again.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [OK] Node.js found.
|
||||
node --version
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ---- Step 2: Install npm dependencies ----
|
||||
echo Installing dependencies (this may take a few minutes)...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo npm install failed. Check the error messages above.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Dependencies installed.
|
||||
echo.
|
||||
|
||||
:: ---- Step 3: Build the project ----
|
||||
echo Building TypeScript project...
|
||||
call npx tsc
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Build failed. Check the error messages above.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Build succeeded.
|
||||
echo.
|
||||
|
||||
:: ---- Step 4: Create default config if missing ----
|
||||
if not exist "config.json" (
|
||||
echo Creating default config.json...
|
||||
echo Please edit config.json with your TeamSpeak server details before starting the bot.
|
||||
) else (
|
||||
echo [OK] config.json already exists.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ---- Done ----
|
||||
echo ============================================
|
||||
echo Setup complete!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo To start the bot, run: scripts\start.bat
|
||||
echo.
|
||||
pause
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
chcp 65001 >nul
|
||||
title TSMusicBot Setup
|
||||
|
||||
:: ============================================================
|
||||
:: TSMusicBot Setup Script (Windows)
|
||||
:: - Auto-detect China network, switch to npmmirror
|
||||
:: - Download native binaries from CDN (避开 GitHub)
|
||||
:: - 自动修复 PowerShell 环境变量
|
||||
:: ============================================================
|
||||
|
||||
set "SCRIPT_VERSION=2.1"
|
||||
set "MIN_NODE_MAJOR=20"
|
||||
set "LOG_FILE=%~dp0..\setup.log"
|
||||
set "FAILED=0"
|
||||
|
||||
:: Resolve project root (one level up from scripts/)
|
||||
cd /d "%~dp0.." || (
|
||||
echo [FATAL] Cannot change to project directory.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "PROJECT_ROOT=%cd%"
|
||||
|
||||
:: ---- Initialize log ----
|
||||
echo. > "%LOG_FILE%"
|
||||
call :log "============================================"
|
||||
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
|
||||
call :log " Started: %date% %time%"
|
||||
call :log " Project root: %PROJECT_ROOT%"
|
||||
call :log "============================================"
|
||||
|
||||
echo ============================================
|
||||
echo TSMusicBot - First-Time Setup (Windows)
|
||||
echo Version %SCRIPT_VERSION%
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Log file: %LOG_FILE%
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 1: Check Node.js
|
||||
:: ============================================================
|
||||
call :step "1/7" "Checking Node.js"
|
||||
|
||||
where node >nul 2>&1
|
||||
if not errorlevel 1 goto :check_node_version
|
||||
|
||||
call :error "Node.js not found in PATH."
|
||||
echo.
|
||||
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
|
||||
echo https://nodejs.org/ (official)
|
||||
echo https://nodejs.cn/ (China mirror, recommended)
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:check_node_version
|
||||
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
|
||||
for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
|
||||
|
||||
call :log "Node.js version: %NODE_VER%"
|
||||
echo [OK] Node.js found: %NODE_VER%
|
||||
|
||||
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
|
||||
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 2: Check npm
|
||||
:: ============================================================
|
||||
call :step "2/7" "Checking npm"
|
||||
|
||||
where npm >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
call :error "npm not found."
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
|
||||
call :log "npm version: %NPM_VER%"
|
||||
echo [OK] npm found: %NPM_VER%
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 3: Detect network and configure mirror
|
||||
:: ============================================================
|
||||
call :step "3/7" "Checking network"
|
||||
|
||||
set "USE_MIRROR=0"
|
||||
set "MIRROR_REGISTRY=https://registry.npmjs.org"
|
||||
|
||||
echo Testing connection to npm registry...
|
||||
call :log "Testing npm registry connectivity..."
|
||||
|
||||
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Cannot reach npm registry quickly, using China mirror.
|
||||
call :log "npm registry unreachable via ping"
|
||||
set "USE_MIRROR=1"
|
||||
) else (
|
||||
echo [OK] npm registry reachable.
|
||||
call :log "npm registry reachable"
|
||||
)
|
||||
|
||||
if "%USE_MIRROR%"=="1" (
|
||||
echo.
|
||||
echo [INFO] Using China mirror (npmmirror.com)
|
||||
call :log "Switching to npmmirror.com"
|
||||
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
|
||||
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
|
||||
) else (
|
||||
set "CDN_MIRROR="
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 4: Install backend dependencies (跳过二进制)
|
||||
:: ============================================================
|
||||
call :step "4/7" "Installing backend dependencies"
|
||||
|
||||
if exist "node_modules\.package-lock.json" (
|
||||
echo Found existing node_modules. Checking integrity...
|
||||
)
|
||||
|
||||
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
|
||||
echo.
|
||||
|
||||
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
|
||||
if errorlevel 1 (
|
||||
call :error "Backend npm install failed."
|
||||
echo Check the log: %LOG_FILE%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Backend dependencies installed.
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 4b: Download native binaries from CDN
|
||||
:: ============================================================
|
||||
call :step "4b/7" "Downloading native binaries"
|
||||
|
||||
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
|
||||
) else (
|
||||
echo [OK] Native binaries installed.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 5: Install frontend dependencies
|
||||
:: ============================================================
|
||||
call :step "5/7" "Installing frontend dependencies"
|
||||
|
||||
if not exist "web\package.json" (
|
||||
call :error "web\package.json not found."
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Running: npm install (in web/)
|
||||
echo.
|
||||
|
||||
pushd web >nul
|
||||
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
|
||||
set "WEB_INSTALL_RESULT=!errorlevel!"
|
||||
popd >nul
|
||||
|
||||
if !WEB_INSTALL_RESULT! neq 0 (
|
||||
call :error "Frontend npm install failed."
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Frontend dependencies installed.
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 6: Build project
|
||||
:: ============================================================
|
||||
call :step "6/7" "Building project"
|
||||
|
||||
echo Running: npm run build
|
||||
echo.
|
||||
|
||||
call npm run build >>"%LOG_FILE%" 2>&1
|
||||
if errorlevel 1 (
|
||||
call :error "Build failed. Check: %LOG_FILE%"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Build succeeded.
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
|
||||
:: ============================================================
|
||||
call :step "7/7" "Checking PowerShell PATH"
|
||||
|
||||
where powershell >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARN] PowerShell not found in PATH.
|
||||
echo Attempting to fix...
|
||||
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
|
||||
if exist "!POWERSHELL_PATH!\powershell.exe" (
|
||||
:: 为用户添加永久 PATH 环境变量
|
||||
echo [INFO] Adding PowerShell to user PATH...
|
||||
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
|
||||
echo [OK] PowerShell added to PATH. Please restart your terminal.
|
||||
) else (
|
||||
echo [WARN] Could not find powershell.exe on this system.
|
||||
echo If you encounter playback issues with some NetEase songs,
|
||||
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
|
||||
echo before running scripts\start.bat
|
||||
)
|
||||
) else (
|
||||
echo [OK] PowerShell found in PATH.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Verify build outputs
|
||||
:: ============================================================
|
||||
echo Verifying build outputs...
|
||||
set "BUILD_OK=1"
|
||||
|
||||
if not exist "dist" (
|
||||
call :error "dist/ directory missing after build."
|
||||
set "BUILD_OK=0"
|
||||
)
|
||||
if not exist "web\dist" (
|
||||
call :error "web\dist/ directory missing after build."
|
||||
set "BUILD_OK=0"
|
||||
)
|
||||
|
||||
if "!BUILD_OK!"=="0" (
|
||||
echo Build completed but expected output is missing.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Build outputs verified.
|
||||
echo.
|
||||
|
||||
if not exist "config.json" (
|
||||
echo [INFO] config.json will be auto-generated on first launch.
|
||||
) else (
|
||||
echo [OK] config.json already exists.
|
||||
)
|
||||
echo.
|
||||
|
||||
:: ============================================================
|
||||
:: Done
|
||||
:: ============================================================
|
||||
call :log "Setup completed successfully at %date% %time%"
|
||||
|
||||
echo ============================================
|
||||
echo Setup Complete!
|
||||
echo ============================================
|
||||
echo.
|
||||
echo Next steps:
|
||||
echo 1. Run: scripts\start.bat
|
||||
echo 2. Open: http://localhost:3000
|
||||
echo.
|
||||
echo Setup log: %LOG_FILE%
|
||||
echo.
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:: ============================================================
|
||||
:: Subroutines
|
||||
:: ============================================================
|
||||
:step
|
||||
echo ---- Step %~1: %~2 ----
|
||||
call :log ""
|
||||
call :log "---- Step %~1: %~2 ----"
|
||||
goto :eof
|
||||
|
||||
:error
|
||||
echo.
|
||||
echo [ERROR] %~1
|
||||
call :log "[ERROR] %~1"
|
||||
goto :eof
|
||||
|
||||
:log
|
||||
echo [%time%] %~1 >> "%LOG_FILE%"
|
||||
goto :eof
|
||||
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
#
|
||||
# TSMusicBot Setup Script (Linux/macOS)
|
||||
# - Auto-detect China network, switch to npmmirror
|
||||
# - Download native binaries from CDN (避开 GitHub)
|
||||
# - One-click setup, same as setup.bat for Windows
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOG_FILE="$PROJECT_DIR/setup.log"
|
||||
|
||||
echo "============================================"
|
||||
echo " TSMusicBot - First-Time Setup (Linux)"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Log file: $LOG_FILE"
|
||||
echo ""
|
||||
|
||||
# ---- Check Node.js ----
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
|
||||
echo " or https://nodejs.cn/ (China mirror)."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Node.js $(node -v)"
|
||||
|
||||
if ! command -v npm &>/dev/null; then
|
||||
echo "[ERROR] npm not found."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] npm v$(npm -v)"
|
||||
echo ""
|
||||
|
||||
# ---- Detect China network ----
|
||||
USE_MIRROR=0
|
||||
MIRROR_REGISTRY="https://registry.npmjs.org"
|
||||
CDN_MIRROR=""
|
||||
|
||||
echo "Testing connection to npm registry..."
|
||||
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
|
||||
echo "[OK] npm registry reachable."
|
||||
else
|
||||
echo "[WARN] Cannot reach npm registry, using China mirror."
|
||||
USE_MIRROR=1
|
||||
fi
|
||||
|
||||
if [ "$USE_MIRROR" = "1" ]; then
|
||||
echo "[INFO] Using China mirror (npmmirror.com)"
|
||||
MIRROR_REGISTRY="https://registry.npmmirror.com"
|
||||
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
|
||||
export npm_config_registry="$MIRROR_REGISTRY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---- Check build tools (needed for native module fallback) ----
|
||||
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
|
||||
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
|
||||
echo " native modules may fail. Install build tools:"
|
||||
echo " sudo apt install build-essential (Ubuntu/Debian)"
|
||||
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
|
||||
echo "---- 1/5: Installing Node.js dependencies ----"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
|
||||
echo "[OK] Dependencies installed."
|
||||
echo ""
|
||||
|
||||
# ---- Step 2: Download native binaries from CDN ----
|
||||
echo "---- 2/5: Downloading native binaries ----"
|
||||
echo ""
|
||||
|
||||
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
|
||||
echo "[OK] Native binaries installed."
|
||||
else
|
||||
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---- Step 3: Install web panel dependencies ----
|
||||
echo "---- 3/5: Installing web panel dependencies ----"
|
||||
echo ""
|
||||
|
||||
if [ -f "web/package.json" ]; then
|
||||
cd "$PROJECT_DIR/web"
|
||||
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
|
||||
cd "$PROJECT_DIR"
|
||||
echo "[OK] Web panel dependencies installed."
|
||||
else
|
||||
echo "[SKIP] web/package.json not found."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ---- Step 4: Build project ----
|
||||
echo "---- 4/5: Building project ----"
|
||||
echo ""
|
||||
|
||||
npm run build 2>&1 | tee -a "$LOG_FILE"
|
||||
echo "[OK] Build succeeded."
|
||||
echo ""
|
||||
|
||||
# ---- Step 5: Verify ----
|
||||
echo "---- 5/5: Verifying build ----"
|
||||
echo ""
|
||||
|
||||
BUILD_OK=1
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "[ERROR] dist/ directory missing."
|
||||
BUILD_OK=0
|
||||
fi
|
||||
if [ -d "web" ] && [ ! -d "web/dist" ]; then
|
||||
echo "[ERROR] web/dist/ directory missing."
|
||||
BUILD_OK=0
|
||||
fi
|
||||
|
||||
if [ "$BUILD_OK" = "0" ]; then
|
||||
echo "Build completed but expected output is missing."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Build outputs verified."
|
||||
echo ""
|
||||
|
||||
if [ ! -f "config.json" ]; then
|
||||
echo "[INFO] config.json will be auto-generated on first launch."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "============================================"
|
||||
echo " Setup Complete!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Run: npm start"
|
||||
echo " 2. Open: http://localhost:3000"
|
||||
echo ""
|
||||
echo "Setup log: $LOG_FILE"
|
||||
echo ""
|
||||
|
||||
+43
-46
@@ -1,47 +1,44 @@
|
||||
@echo off
|
||||
title TSMusicBot
|
||||
echo Starting TSMusicBot...
|
||||
echo.
|
||||
@echo off
|
||||
title TSMusicBot
|
||||
echo Starting TSMusicBot...
|
||||
echo.
|
||||
|
||||
:: Check if node is available
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Node.js is not installed.
|
||||
echo Run scripts\setup.bat first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Resolve project root (one level up from scripts/)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
:: Check if dependencies are installed
|
||||
if not exist "node_modules" (
|
||||
echo Dependencies not found. Please run scripts\setup.bat first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check if build output exists
|
||||
if not exist "dist" (
|
||||
echo Build not found. Please run scripts\setup.bat first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
|
||||
where powershell >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
|
||||
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
|
||||
)
|
||||
)
|
||||
|
||||
:: Start the application
|
||||
node dist/index.js
|
||||
|
||||
pause
|
||||
|
||||
:: Check if node is available
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Node.js is not installed.
|
||||
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Resolve project root (one level up from scripts/)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
:: Install dependencies if needed
|
||||
if not exist "node_modules" (
|
||||
echo Installing dependencies...
|
||||
call npm install --production
|
||||
if %errorlevel% neq 0 (
|
||||
echo Failed to install dependencies.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: Build if dist/ doesn't exist
|
||||
if not exist "dist" (
|
||||
echo Building project...
|
||||
call npx tsc
|
||||
if %errorlevel% neq 0 (
|
||||
echo Build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
|
||||
echo FFmpeg is bundled via node_modules (ffmpeg-static).
|
||||
echo.
|
||||
|
||||
:: Start the application
|
||||
node dist/index.js
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row.
|
||||
|
||||
Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and
|
||||
asserts the row disappears without any page reload. Does not touch any
|
||||
existing user bot.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
EPHEMERAL_NAME = "rmbot_test"
|
||||
EPHEMERAL_NICK = "RmBotTest"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.text else None
|
||||
|
||||
|
||||
def cleanup():
|
||||
for b in api("/api/bot/")["bots"]:
|
||||
if b["name"] == EPHEMERAL_NAME:
|
||||
try:
|
||||
api(f"/api/bot/{b['id']}", method="DELETE")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
cleanup()
|
||||
new = api(
|
||||
"/api/bot/",
|
||||
method="POST",
|
||||
json={
|
||||
"name": EPHEMERAL_NAME,
|
||||
"serverAddress": "127.0.0.1",
|
||||
"serverPort": 9987,
|
||||
"nickname": EPHEMERAL_NICK,
|
||||
"autoStart": False,
|
||||
},
|
||||
)
|
||||
bot_id = new["id"]
|
||||
print(f"[setup] created ephemeral bot {bot_id[:8]}")
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto(BASE)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.8)
|
||||
|
||||
# Open dropdown and confirm the new bot row is present
|
||||
page.locator(".bot-selector-btn").click()
|
||||
page.wait_for_selector(".bot-dropdown")
|
||||
rows_before = page.locator(".bot-dropdown-row").count()
|
||||
print(f"[ui] dropdown rows before remove: {rows_before}")
|
||||
|
||||
# Match the ephemeral row by its name text
|
||||
present = (
|
||||
page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count()
|
||||
)
|
||||
assert present == 1, f"ephemeral row not found (got {present})"
|
||||
|
||||
# Delete via API
|
||||
api(f"/api/bot/{bot_id}", method="DELETE")
|
||||
print("[api] deleted bot")
|
||||
|
||||
# Wait up to 4s for UI to drop the row
|
||||
removed = False
|
||||
for _ in range(40):
|
||||
if (
|
||||
page.locator(
|
||||
".bot-dropdown-row", has_text=EPHEMERAL_NAME
|
||||
).count()
|
||||
== 0
|
||||
):
|
||||
removed = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
rows_after = page.locator(".bot-dropdown-row").count()
|
||||
print(f"[ui] dropdown rows after remove: {rows_after}")
|
||||
assert removed, "ephemeral row did not disappear from UI after DELETE"
|
||||
assert rows_after == rows_before - 1, (
|
||||
f"row count mismatch: before={rows_before} after={rows_after}"
|
||||
)
|
||||
print("[PASS] bot removal propagates to UI via WS")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Corner case regressions that go beyond Bugs A/B/C.
|
||||
|
||||
A. Race — disconnect() called during connect()'s awaited handshake must
|
||||
NOT leave the bot reporting connected=true afterwards.
|
||||
|
||||
B. Config-only commands (vol, mode, clear) must work even when the bot is
|
||||
disconnected (UI should stay usable while the bot is offline).
|
||||
|
||||
C. After a disconnect mid-playback, the player must not be able to
|
||||
auto-advance to the next queued song (trackEnd → resolveAndPlay).
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def test_config_commands_when_disconnected(bot_id):
|
||||
"""B. vol/mode/clear should succeed while bot is disconnected."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
|
||||
# volume is the simplest config-only command
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json={"volume": 60},
|
||||
)
|
||||
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json={"mode": "seq"},
|
||||
)
|
||||
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
r = api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
|
||||
|
||||
print("[PASS] config commands (vol/mode/clear) work when disconnected")
|
||||
|
||||
|
||||
def test_play_rejected_when_disconnected(bot_id):
|
||||
"""B (negative). play/add/next/prev should still be rejected."""
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, f"play should fail while disconnected"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json={"query": "test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, f"add should fail while disconnected"
|
||||
|
||||
r = api(f"/api/player/{bot_id}/next", method="POST")
|
||||
assert r.status_code >= 400, f"next should fail while disconnected"
|
||||
|
||||
print("[PASS] audio commands (play/add/next) rejected when disconnected")
|
||||
|
||||
|
||||
def test_disconnect_during_connect_race(bot_id):
|
||||
"""A. disconnect() called while connect() is awaiting must win the race.
|
||||
|
||||
Fires a stop 200ms into a start call; after things settle the bot's
|
||||
connected state must be stable (either cleanly disconnected, or cleanly
|
||||
connected if the stop happened after connect completed). It must NOT
|
||||
end up in a weird state where connected=true but a subsequent query
|
||||
shows inconsistent data.
|
||||
"""
|
||||
# Ensure disconnected first
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1) # give TS server a moment to forget us
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.2)
|
||||
try:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
try:
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
except Exception as e:
|
||||
r = None
|
||||
print(f"[info] start threw: {e}")
|
||||
|
||||
# Wait for all state transitions to settle
|
||||
time.sleep(2)
|
||||
|
||||
b = get_bot(bot_id)
|
||||
# The key invariant: if connected is false, playing must also be false;
|
||||
# if connected is true, the transport is actually up (we can issue
|
||||
# another command without error).
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: connected={b['connected']} playing={b['playing']}"
|
||||
)
|
||||
print(
|
||||
f"[PASS] disconnect-during-connect race — final state consistent "
|
||||
f"(connected={b['connected']} playing={b['playing']})"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_guard(bot_id):
|
||||
"""C. resolveAndPlay on a disconnected bot is a no-op.
|
||||
|
||||
We can't directly invoke resolveAndPlay from the API, but we can
|
||||
verify by checking that after a stop, the bot stays idle even if we
|
||||
wait for a trackEnd-like event to fire.
|
||||
"""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1.5) # more than a frame cycle
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], (
|
||||
f"player should stay stopped after disconnect: {b}"
|
||||
)
|
||||
print("[PASS] player stays idle after disconnect (no ghost autoplay)")
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
|
||||
|
||||
try:
|
||||
test_config_commands_when_disconnected(bot_id)
|
||||
test_play_rejected_when_disconnected(bot_id)
|
||||
test_resolve_guard(bot_id)
|
||||
test_disconnect_during_connect_race(bot_id)
|
||||
print("ALL GREEN")
|
||||
finally:
|
||||
if initial:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] connected={get_bot(bot_id)['connected']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,852 @@
|
||||
"""Comprehensive feature + corner-case test for TSMusicBot against a
|
||||
local TeamSpeak 3 server.
|
||||
|
||||
Exercises every major HTTP endpoint, the WebSocket state-broadcast path,
|
||||
all music providers, bot lifecycle transitions, and a handful of races
|
||||
that have burned us in the past. Designed to be safe to run against a
|
||||
real installation: captures the target bot's initial connected/volume/
|
||||
mode settings and restores them in `finally`.
|
||||
|
||||
Usage:
|
||||
"C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py
|
||||
|
||||
Exit code: 0 if every non-skipped test passed, 1 otherwise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
POLL_INTERVAL = 0.15
|
||||
CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace
|
||||
ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy
|
||||
|
||||
|
||||
# ----------------------------- HTTP helpers ---------------------------------
|
||||
|
||||
|
||||
def api(path: str, method: str = "GET", json_body: Any = None):
|
||||
"""Return (status_code, body). Never raises."""
|
||||
try:
|
||||
fn = getattr(requests, method.lower())
|
||||
r = fn(f"{BASE}{path}", json=json_body, timeout=30)
|
||||
try:
|
||||
return r.status_code, r.json()
|
||||
except Exception:
|
||||
return r.status_code, r.text
|
||||
except Exception as e:
|
||||
return None, f"<{type(e).__name__}: {e}>"
|
||||
|
||||
|
||||
def get_bot(bot_id: str) -> dict | None:
|
||||
_, data = api("/api/bot/")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return next((b for b in data.get("bots", []) if b["id"] == bot_id), None)
|
||||
|
||||
|
||||
def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool:
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
b = get_bot(bot_id)
|
||||
if b is not None and b["connected"] is want:
|
||||
return True
|
||||
time.sleep(POLL_INTERVAL)
|
||||
return False
|
||||
|
||||
|
||||
def start_and_wait(bot_id: str, retries: int = 2) -> bool:
|
||||
"""Start the bot, tolerating transient TS3 anti-flood by retrying with
|
||||
exponential backoff. Returns True only when the bot reports connected."""
|
||||
for attempt in range(retries + 1):
|
||||
s, _ = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
if s == 200 and wait_connected(bot_id, True):
|
||||
return True
|
||||
# If /start returned an error (e.g. connect timeout from our 15s
|
||||
# deadline), back off and retry — TS3 server-side anti-flood
|
||||
# usually clears in a few seconds.
|
||||
if attempt < retries:
|
||||
time.sleep(3.0 * (attempt + 1))
|
||||
# Make sure we're fully stopped before the next attempt so
|
||||
# oldBot.disconnect() doesn't double-fire
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False, timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
def stop_and_wait(bot_id: str) -> bool:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
return wait_connected(bot_id, False)
|
||||
|
||||
|
||||
def assert_started(bot_id: str):
|
||||
"""Helper that raises with a clear message when start fails so test
|
||||
output points at 'could not connect' rather than an empty assertion."""
|
||||
if not start_and_wait(bot_id):
|
||||
raise AssertionError(
|
||||
"could not bring bot online (TS3 server may be anti-flooding "
|
||||
"or unreachable)"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------- test runner ----------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
status: str # PASS / FAIL / ERROR / SKIP
|
||||
detail: str = ""
|
||||
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
results: list[TestResult] = []
|
||||
|
||||
|
||||
def run(name: str, fn):
|
||||
global passed, failed
|
||||
try:
|
||||
fn()
|
||||
print(f" [PASS] {name}")
|
||||
passed += 1
|
||||
results.append(TestResult(name, "PASS"))
|
||||
except AssertionError as e:
|
||||
print(f" [FAIL] {name}: {e}")
|
||||
failed += 1
|
||||
results.append(TestResult(name, "FAIL", str(e)))
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {name}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}"))
|
||||
|
||||
|
||||
def skip(name: str, reason: str):
|
||||
global skipped
|
||||
print(f" [SKIP] {name} ({reason})")
|
||||
skipped += 1
|
||||
results.append(TestResult(name, "SKIP", reason))
|
||||
|
||||
|
||||
# ----------------------------- test groups ----------------------------------
|
||||
|
||||
|
||||
def group_infrastructure(bot_id: str):
|
||||
print("\n== infrastructure ==")
|
||||
|
||||
def t_health():
|
||||
s, d = api("/api/health")
|
||||
assert s == 200, f"health returned {s}"
|
||||
assert d.get("status") == "ok"
|
||||
run("GET /api/health", t_health)
|
||||
|
||||
def t_list_bots():
|
||||
s, d = api("/api/bot/")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("bots"), list)
|
||||
assert any(b["id"] == bot_id for b in d["bots"])
|
||||
run("GET /api/bot/ lists target bot", t_list_bots)
|
||||
|
||||
def t_bot_config():
|
||||
s, d = api(f"/api/bot/{bot_id}/config")
|
||||
assert s == 200
|
||||
assert d["id"] == bot_id
|
||||
assert "identity" in d
|
||||
assert "serverAddress" in d and "nickname" in d
|
||||
assert "serverPassword" in d, "serverPassword field missing"
|
||||
run("GET /api/bot/:id/config returns all fields", t_bot_config)
|
||||
|
||||
def t_404_on_unknown_bot():
|
||||
s, _ = api("/api/bot/does-not-exist/config")
|
||||
assert s == 404
|
||||
run("404 on unknown bot id", t_404_on_unknown_bot)
|
||||
|
||||
def t_quality_shape():
|
||||
s, d = api("/api/music/quality")
|
||||
assert s == 200
|
||||
for p in ("netease", "qq", "bilibili"):
|
||||
assert p in d, f"{p} missing from quality response"
|
||||
run("GET /api/music/quality shape", t_quality_shape)
|
||||
|
||||
|
||||
def group_auth_status():
|
||||
print("\n== auth status per platform ==")
|
||||
|
||||
def t_netease_ok():
|
||||
s, d = api("/api/auth/status?platform=netease")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "netease"
|
||||
assert "loggedIn" in d
|
||||
run("auth status netease", t_netease_ok)
|
||||
|
||||
def t_qq_ok():
|
||||
s, d = api("/api/auth/status?platform=qq")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "qq"
|
||||
run("auth status qq", t_qq_ok)
|
||||
|
||||
def t_bilibili_ok():
|
||||
s, d = api("/api/auth/status?platform=bilibili")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "bilibili"
|
||||
run("auth status bilibili", t_bilibili_ok)
|
||||
|
||||
def t_youtube_routed():
|
||||
# Regression: /auth/status?platform=youtube used to fall through
|
||||
# to NetEase and leak the NetEase user's nickname/avatar.
|
||||
s, d = api("/api/auth/status?platform=youtube")
|
||||
assert s == 200
|
||||
assert d.get("platform") == "youtube", (
|
||||
f"youtube auth status leaked to {d.get('platform')}"
|
||||
)
|
||||
run("auth status youtube routes correctly", t_youtube_routed)
|
||||
|
||||
def t_youtube_cookie_rejected():
|
||||
s, d = api(
|
||||
"/api/auth/cookie",
|
||||
method="POST",
|
||||
json_body={"platform": "youtube", "cookie": "fake"},
|
||||
)
|
||||
assert s == 400, f"youtube cookie should be rejected, got {s}: {d}"
|
||||
run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected)
|
||||
|
||||
|
||||
def group_search():
|
||||
print("\n== multi-platform search ==")
|
||||
|
||||
def search(platform: str, query: str = "test"):
|
||||
return api(f"/api/music/search?q={query}&platform={platform}&limit=1")
|
||||
|
||||
def t_netease():
|
||||
s, d = search("netease")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("netease search", t_netease)
|
||||
|
||||
def t_qq():
|
||||
s, d = search("qq")
|
||||
assert s == 200
|
||||
# QQ may return 0 results if no cookie, but shouldn't error
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("qq search (empty ok)", t_qq)
|
||||
|
||||
def t_bilibili():
|
||||
s, d = search("bilibili")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("songs"), list)
|
||||
run("bilibili search", t_bilibili)
|
||||
|
||||
def t_missing_query():
|
||||
s, _ = api("/api/music/search?platform=netease")
|
||||
assert s == 400, "missing q should 400"
|
||||
run("400 on missing query", t_missing_query)
|
||||
|
||||
_, auth = api("/api/auth/status?platform=youtube")
|
||||
youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True
|
||||
|
||||
if youtube_available:
|
||||
def t_youtube():
|
||||
s, d = search("youtube", "lofi")
|
||||
assert s == 200
|
||||
songs = d.get("songs", [])
|
||||
assert len(songs) >= 1, "expected at least 1 YouTube result"
|
||||
assert songs[0]["platform"] == "youtube"
|
||||
run("youtube search (yt-dlp installed)", t_youtube)
|
||||
else:
|
||||
skip("youtube search", "yt-dlp not installed")
|
||||
|
||||
|
||||
def group_lifecycle(bot_id: str):
|
||||
print("\n== connection lifecycle ==")
|
||||
|
||||
def t_stop_from_any_state():
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
assert wait_connected(bot_id, False), "bot did not stop"
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], "playing should be false after stop"
|
||||
run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state)
|
||||
|
||||
def t_start_completes_quickly():
|
||||
t0 = time.time()
|
||||
s, d = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
elapsed = time.time() - t0
|
||||
assert s == 200, f"start failed: {d}"
|
||||
assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)"
|
||||
assert wait_connected(bot_id, True)
|
||||
run("start completes well under 15s deadline", t_start_completes_quickly)
|
||||
|
||||
def t_identity_persists():
|
||||
assert_started(bot_id)
|
||||
_, cfg1 = api(f"/api/bot/{bot_id}/config")
|
||||
id1 = cfg1["identity"]
|
||||
assert id1, "identity empty after first start"
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
assert_started(bot_id)
|
||||
_, cfg2 = api(f"/api/bot/{bot_id}/config")
|
||||
assert cfg2["identity"] == id1, (
|
||||
f"identity changed across restart: {id1} \u2192 {cfg2['identity']}"
|
||||
)
|
||||
run("identity preserved across stop/start", t_identity_persists)
|
||||
|
||||
|
||||
def group_playback(bot_id: str):
|
||||
print("\n== playback ==")
|
||||
|
||||
# Bring the bot online ONCE for the whole playback group, then only
|
||||
# toggle player state (play/pause/stop) between tests. This keeps the
|
||||
# TS3 reconnect count for this group at exactly 1.
|
||||
assert_started(bot_id)
|
||||
|
||||
def t_play_song():
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert s == 200, f"play failed: {d}"
|
||||
time.sleep(1.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"] is True, f"not playing after /play: {b}"
|
||||
assert b["currentSong"] is not None
|
||||
run("play netease song \u2192 playing=true", t_play_song)
|
||||
|
||||
def t_pause_resume():
|
||||
# Previous test left a song playing
|
||||
api(f"/api/player/{bot_id}/pause", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["paused"] is True, f"pause failed: {b}"
|
||||
api(f"/api/player/{bot_id}/resume", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}"
|
||||
run("pause \u2192 paused, resume \u2192 playing", t_pause_resume)
|
||||
|
||||
def t_volume_change():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": 42},
|
||||
)
|
||||
assert s == 200
|
||||
time.sleep(0.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == 42, f"volume not applied: {b['volume']}"
|
||||
run("volume change", t_volume_change)
|
||||
|
||||
def t_mode_cycle():
|
||||
for m in ("seq", "loop", "random", "rloop"):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m}
|
||||
)
|
||||
assert s == 200
|
||||
b = get_bot(bot_id)
|
||||
assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}"
|
||||
run("all four play modes apply", t_mode_cycle)
|
||||
|
||||
def t_queue_endpoint():
|
||||
s, d = api(f"/api/player/{bot_id}/queue")
|
||||
assert s == 200
|
||||
assert isinstance(d.get("queue"), list)
|
||||
assert "status" in d
|
||||
run("GET /player/:id/queue returns queue+status", t_queue_endpoint)
|
||||
|
||||
def t_elapsed_endpoint():
|
||||
s, d = api(f"/api/player/{bot_id}/elapsed")
|
||||
assert s == 200
|
||||
elapsed = d.get("elapsed")
|
||||
assert isinstance(elapsed, (int, float)) and elapsed >= 0, (
|
||||
f"elapsed should be non-negative number: {elapsed}"
|
||||
)
|
||||
run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint)
|
||||
|
||||
def t_add_autoplay_on_idle():
|
||||
# This specific test needs an IDLE bot — stop first (but keep
|
||||
# connected), then add and confirm auto-play.
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}"
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert s == 200
|
||||
msg = d.get("message", "") if isinstance(d, dict) else ""
|
||||
assert "Now playing" in msg, (
|
||||
f"add on idle bot should auto-play, got: {msg!r}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"] is True, f"not playing after add: {b}"
|
||||
run("add on idle bot auto-plays", t_add_autoplay_on_idle)
|
||||
|
||||
# Leave the bot in a clean state for the next group
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
|
||||
|
||||
def group_queue_ops(bot_id: str):
|
||||
print("\n== queue operations ==")
|
||||
assert_started(bot_id)
|
||||
|
||||
def t_clear():
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(0.8)
|
||||
api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "lemon tree", "platform": "netease"},
|
||||
)
|
||||
time.sleep(0.6)
|
||||
b_before = get_bot(bot_id)
|
||||
assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}"
|
||||
api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
time.sleep(0.4)
|
||||
b_after = get_bot(bot_id)
|
||||
assert b_after["queueSize"] == 0
|
||||
run("clear queue empties it", t_clear)
|
||||
|
||||
def t_play_at_invalid_preserves_playback():
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.2)
|
||||
assert get_bot(bot_id)["playing"]
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/play-at",
|
||||
method="POST",
|
||||
json_body={"index": 9999},
|
||||
)
|
||||
assert s == 400, f"invalid index should 400, got {s}"
|
||||
time.sleep(0.4)
|
||||
b = get_bot(bot_id)
|
||||
assert b["playing"], "invalid play-at killed the current song"
|
||||
run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback)
|
||||
|
||||
def t_play_at_negative_rejected():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/play-at",
|
||||
method="POST",
|
||||
json_body={"index": -1},
|
||||
)
|
||||
assert s == 400
|
||||
run("play-at with negative index rejected", t_play_at_negative_rejected)
|
||||
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
|
||||
|
||||
def group_input_validation(bot_id: str):
|
||||
print("\n== HTTP input validation ==")
|
||||
|
||||
def t_volume_out_of_range():
|
||||
for bad in (150, -10, 1000, -1):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": bad},
|
||||
)
|
||||
assert s == 400, f"volume={bad} should 400, got {s}"
|
||||
run("volume out-of-range rejected (400)", t_volume_out_of_range)
|
||||
|
||||
def t_volume_wrong_type():
|
||||
for bad in ("50", None, [50], {"v": 50}):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": bad},
|
||||
)
|
||||
assert s == 400, f"volume={bad!r} should 400, got {s}"
|
||||
run("volume wrong-type rejected (400)", t_volume_wrong_type)
|
||||
|
||||
def t_volume_missing():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume", method="POST", json_body={}
|
||||
)
|
||||
assert s == 400
|
||||
run("volume missing rejected (400)", t_volume_missing)
|
||||
|
||||
def t_volume_valid():
|
||||
for good in (0, 1, 50, 100):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": good},
|
||||
)
|
||||
assert s == 200, f"volume={good} should succeed, got {s}"
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == good, f"volume not applied: {b['volume']}"
|
||||
run("valid volumes apply", t_volume_valid)
|
||||
|
||||
def t_mode_invalid():
|
||||
for bad in ("bogus", "", None, 1, "SEQ"):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json_body={"mode": bad},
|
||||
)
|
||||
assert s == 400, f"mode={bad!r} should 400, got {s}"
|
||||
run("mode invalid rejected (400)", t_mode_invalid)
|
||||
|
||||
def t_mode_missing():
|
||||
s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={})
|
||||
assert s == 400
|
||||
run("mode missing rejected (400)", t_mode_missing)
|
||||
|
||||
|
||||
def group_disconnect_corners(bot_id: str):
|
||||
print("\n== disconnected-bot corners ==")
|
||||
|
||||
def t_play_rejected():
|
||||
assert stop_and_wait(bot_id)
|
||||
s, d = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "x", "platform": "netease"},
|
||||
)
|
||||
assert s >= 400
|
||||
err = (d.get("error") or "") if isinstance(d, dict) else ""
|
||||
assert "not connected" in err.lower(), f"expected 'not connected' error: {d}"
|
||||
run("play rejected while disconnected", t_play_rejected)
|
||||
|
||||
def t_add_rejected():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/add",
|
||||
method="POST",
|
||||
json_body={"query": "x", "platform": "netease"},
|
||||
)
|
||||
assert s >= 400
|
||||
run("add rejected while disconnected", t_add_rejected)
|
||||
|
||||
def t_next_rejected():
|
||||
s, _ = api(f"/api/player/{bot_id}/next", method="POST")
|
||||
assert s >= 400
|
||||
run("next rejected while disconnected", t_next_rejected)
|
||||
|
||||
def t_volume_allowed():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": 60},
|
||||
)
|
||||
assert s == 200, "volume should work while disconnected"
|
||||
run("volume allowed while disconnected", t_volume_allowed)
|
||||
|
||||
def t_mode_allowed():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"}
|
||||
)
|
||||
assert s == 200, "mode should work while disconnected"
|
||||
run("mode allowed while disconnected", t_mode_allowed)
|
||||
|
||||
def t_clear_allowed():
|
||||
s, _ = api(f"/api/player/{bot_id}/clear", method="POST")
|
||||
assert s == 200, "clear should work while disconnected"
|
||||
run("clear allowed while disconnected", t_clear_allowed)
|
||||
|
||||
def t_player_state_clean():
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"] and not b["paused"], f"state leak: {b}"
|
||||
run("no player state leak while disconnected", t_player_state_clean)
|
||||
|
||||
|
||||
def group_seek(bot_id: str):
|
||||
print("\n== seek validation ==")
|
||||
|
||||
def t_negative():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5}
|
||||
)
|
||||
assert s == 400
|
||||
run("negative seek rejected", t_negative)
|
||||
|
||||
def t_string():
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json_body={"position": "abc"},
|
||||
)
|
||||
assert s == 400
|
||||
run("string seek rejected", t_string)
|
||||
|
||||
def t_nan_literal():
|
||||
r = requests.post(
|
||||
f"{BASE}/api/player/{bot_id}/seek",
|
||||
data='{"position": NaN}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}"
|
||||
run("NaN literal seek rejected", t_nan_literal)
|
||||
|
||||
def t_valid_seek():
|
||||
# Seek needs a live connection + playing song. The disconnect-
|
||||
# corners group right before this one left the bot disconnected.
|
||||
assert_started(bot_id)
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.5)
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json_body={"position": 25},
|
||||
)
|
||||
assert s == 200
|
||||
time.sleep(0.5)
|
||||
_, d = api(f"/api/player/{bot_id}/elapsed")
|
||||
elapsed = d.get("elapsed")
|
||||
assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, (
|
||||
f"elapsed after seek(25) wrong: {elapsed}"
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
run("valid seek produces finite elapsed", t_valid_seek)
|
||||
|
||||
|
||||
def group_races(bot_id: str):
|
||||
print("\n== race conditions ==")
|
||||
|
||||
def t_disconnect_during_connect():
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.2)
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
time.sleep(2)
|
||||
b = get_bot(bot_id)
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: {b}"
|
||||
)
|
||||
run("disconnect during connect", t_disconnect_during_connect)
|
||||
|
||||
def t_stop_during_url_resolve():
|
||||
assert stop_and_wait(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
assert_started(bot_id)
|
||||
|
||||
def delayed_stop():
|
||||
time.sleep(0.15)
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(3)
|
||||
b = get_bot(bot_id)
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state: {b}"
|
||||
)
|
||||
run("stop during URL resolve", t_stop_during_url_resolve)
|
||||
|
||||
def t_rapid_volume_change():
|
||||
# Volume is a config-only command and works while disconnected,
|
||||
# so this test deliberately doesn't call assert_started — we're
|
||||
# validating the API's last-write-wins behavior, not the TS
|
||||
# transport. That also spares the TS3 anti-flood budget.
|
||||
for v in (10, 25, 50, 75, 100, 1):
|
||||
s, _ = api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": v},
|
||||
)
|
||||
assert s == 200, f"volume POST failed: {s}"
|
||||
time.sleep(0.3)
|
||||
b = get_bot(bot_id)
|
||||
assert b["volume"] == 1, f"final volume wrong: {b['volume']}"
|
||||
run("rapid volume changes converge", t_rapid_volume_change)
|
||||
|
||||
|
||||
def group_websocket(bot_id: str):
|
||||
print("\n== websocket broadcasts ==")
|
||||
try:
|
||||
from websocket import create_connection
|
||||
except Exception as e:
|
||||
skip("websocket state broadcasts", f"websocket lib unavailable: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
ws = create_connection("ws://localhost:3000/ws", timeout=5)
|
||||
except Exception as e:
|
||||
skip("websocket state broadcasts", f"connect failed: {e}")
|
||||
return
|
||||
|
||||
ws.settimeout(0.3)
|
||||
messages: list[dict] = []
|
||||
stop_reader = threading.Event()
|
||||
|
||||
def reader():
|
||||
while not stop_reader.is_set():
|
||||
try:
|
||||
raw = ws.recv()
|
||||
if not raw:
|
||||
break
|
||||
try:
|
||||
messages.append(json.loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# recv() timeout or closed — keep trying until stop_reader
|
||||
if stop_reader.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
reader_thread = threading.Thread(target=reader, daemon=True)
|
||||
reader_thread.start()
|
||||
|
||||
try:
|
||||
def t_init():
|
||||
time.sleep(0.6)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "init" in types, f"no init message; got: {types}"
|
||||
run("init message on connect", t_init)
|
||||
|
||||
def t_state_change_on_play():
|
||||
assert_started(bot_id)
|
||||
messages.clear()
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json_body={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.5)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "stateChange" in types, (
|
||||
f"no stateChange after play; got types: {types}"
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
run("stateChange broadcast on play", t_state_change_on_play)
|
||||
|
||||
def t_bot_disconnected_event():
|
||||
assert_started(bot_id)
|
||||
messages.clear()
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
time.sleep(1.5)
|
||||
types = [m.get("type") for m in messages]
|
||||
assert "botDisconnected" in types or "stateChange" in types, (
|
||||
f"no disconnect event; got: {types}"
|
||||
)
|
||||
run("botDisconnected event on stop", t_bot_disconnected_event)
|
||||
finally:
|
||||
stop_reader.set()
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ----------------------------- main ----------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_, data = api("/api/bot/")
|
||||
if not isinstance(data, dict) or not data.get("bots"):
|
||||
print("[fatal] no bots registered — create one via the WebUI first")
|
||||
return 2
|
||||
|
||||
target = data["bots"][0]
|
||||
bot_id = target["id"]
|
||||
initial_connected = target["connected"]
|
||||
initial_volume = target["volume"]
|
||||
initial_mode = target["playMode"]
|
||||
|
||||
print(f"[init] target bot = {bot_id[:8]} ({target['name']})")
|
||||
print(
|
||||
f"[init] initial state: connected={initial_connected} "
|
||||
f"volume={initial_volume} mode={initial_mode}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Read-only / no-lifecycle groups first — they don't consume TS3
|
||||
# anti-flood budget.
|
||||
group_infrastructure(bot_id)
|
||||
group_auth_status()
|
||||
group_search()
|
||||
|
||||
# Lifecycle-heavy groups — interleave with small breathers so the
|
||||
# TS3 server's per-IP reconnect limit doesn't start throttling us.
|
||||
group_lifecycle(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_playback(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_queue_ops(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_input_validation(bot_id)
|
||||
group_disconnect_corners(bot_id)
|
||||
group_seek(bot_id)
|
||||
time.sleep(ANTIFLOOD_BREATHER)
|
||||
|
||||
group_races(bot_id)
|
||||
# Extra breather before websocket group — races is the heaviest
|
||||
# consumer of TS3 reconnect budget (disconnect-during-connect and
|
||||
# stop-during-url-resolve each burn one cycle), and the websocket
|
||||
# group needs a clean reconnect to observe live state broadcasts.
|
||||
time.sleep(ANTIFLOOD_BREATHER * 3)
|
||||
|
||||
group_websocket(bot_id)
|
||||
finally:
|
||||
# Restore initial state — this runs even if a test raised
|
||||
try:
|
||||
api(
|
||||
f"/api/player/{bot_id}/volume",
|
||||
method="POST",
|
||||
json_body={"volume": initial_volume},
|
||||
)
|
||||
api(
|
||||
f"/api/player/{bot_id}/mode",
|
||||
method="POST",
|
||||
json_body={"mode": initial_mode},
|
||||
)
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
if initial_connected:
|
||||
start_and_wait(bot_id)
|
||||
else:
|
||||
stop_and_wait(bot_id)
|
||||
except Exception as e:
|
||||
print(f"[warn] restore failed: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" PASSED: {passed}")
|
||||
print(f" FAILED: {failed}")
|
||||
print(f" SKIPPED: {skipped}")
|
||||
print("=" * 60)
|
||||
|
||||
if failed > 0:
|
||||
print("\nFailed tests:")
|
||||
for r in results:
|
||||
if r.status in ("FAIL", "ERROR"):
|
||||
print(f" [{r.status}] {r.name}: {r.detail}")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,106 @@
|
||||
// Empirically verifies the PowerShell-download workaround for jdymusic CDN
|
||||
// blocks Node.js HTTP. Runs A/B against the same fresh /jdymusic/ URL:
|
||||
// A) ffmpeg direct with browser UA (the previous fix in this branch)
|
||||
// B) PowerShell WebClient -> temp file -> ffmpeg from file (the new fix)
|
||||
// Reports bytes received + exit code + stderr-tail for each.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, statSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildFfmpegArgs } from "../dist/audio/player.js";
|
||||
|
||||
const url = process.argv[2];
|
||||
if (!url) {
|
||||
console.error("usage: node scripts/test_jdymusic_powershell.mjs <jdymusic_url>");
|
||||
process.exit(2);
|
||||
}
|
||||
if (!url.includes("/jdymusic/")) {
|
||||
console.error("warning: this script targets /jdymusic/ URLs specifically");
|
||||
}
|
||||
|
||||
const FFMPEG = "ffmpeg";
|
||||
const BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
const TIMEOUT_MS = 20_000;
|
||||
|
||||
function runFfmpeg(label, args, stdinSource) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(FFMPEG, args, { stdio: [stdinSource ?? "ignore", "pipe", "pipe"] });
|
||||
let bytes = 0;
|
||||
let stderrTail = "";
|
||||
let killed = false;
|
||||
proc.stdout.on("data", (chunk) => { bytes += chunk.length; });
|
||||
proc.stderr.on("data", (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-1500); });
|
||||
const timer = setTimeout(() => { killed = true; proc.kill("SIGTERM"); }, TIMEOUT_MS);
|
||||
proc.on("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ label, bytes, code, signal, killed, stderrTail });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadViaPowerShell(targetUrl, outFile) {
|
||||
return new Promise((resolve) => {
|
||||
const psScript = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$ProgressPreference = 'SilentlyContinue'",
|
||||
"$wc = New-Object System.Net.WebClient",
|
||||
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
|
||||
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
|
||||
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
|
||||
].join("; ");
|
||||
const ps = spawn(
|
||||
"powershell",
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
DL_URL: targetUrl,
|
||||
DL_OUT: outFile,
|
||||
DL_UA: BROWSER_UA,
|
||||
DL_REFERER: "https://music.163.com/",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let stderr = "";
|
||||
ps.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
ps.on("exit", (code) => resolve({ code, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`URL: ${url}\n`);
|
||||
|
||||
console.log("[A] ffmpeg direct (browser UA via -headers)");
|
||||
const a = await runFfmpeg("A", buildFfmpegArgs(url, 0));
|
||||
console.log(` code=${a.code} bytes=${a.bytes} killed=${a.killed}`);
|
||||
console.log(` stderr-tail: ${a.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
|
||||
|
||||
console.log("[B] PowerShell WebClient -> temp file -> ffmpeg -i tempfile");
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-test-"));
|
||||
const tempFile = join(tempDir, "song.audio");
|
||||
const psStart = Date.now();
|
||||
const dl = await downloadViaPowerShell(url, tempFile);
|
||||
const psMs = Date.now() - psStart;
|
||||
if (dl.code !== 0) {
|
||||
console.log(` PowerShell download FAILED: code=${dl.code}`);
|
||||
console.log(` stderr: ${dl.stderr.slice(-500)}`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
const dlSize = statSync(tempFile).size;
|
||||
console.log(` PowerShell downloaded ${dlSize} bytes in ${psMs}ms`);
|
||||
|
||||
const b = await runFfmpeg("B", buildFfmpegArgs(tempFile, 0));
|
||||
console.log(` ffmpeg-from-file: code=${b.code} bytes=${b.bytes} killed=${b.killed}`);
|
||||
console.log(` stderr-tail: ${b.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
const aBlocked = a.bytes === 0 && !a.killed;
|
||||
const bWorked = b.bytes > 100_000;
|
||||
console.log(
|
||||
`Verdict: direct ${aBlocked ? "BLOCKED" : "OK"} ; ` +
|
||||
`powershell-then-ffmpeg ${bWorked ? "WORKED" : "FAILED"}`,
|
||||
);
|
||||
@@ -0,0 +1,166 @@
|
||||
"""More corner-case regressions.
|
||||
|
||||
A. resolveAndPlay disconnect-during-URL-resolve race
|
||||
The bot checks !this.connected at the top of resolveAndPlay, but the
|
||||
URL-resolve await can take several seconds. If stop is called during
|
||||
that window, playback would previously start on a disconnected bot.
|
||||
|
||||
B. /seek NaN/Infinity rejection
|
||||
typeof NaN === "number" and NaN < 0 is false, so a plain range check
|
||||
leaks NaN through and corrupts seekOffset / getElapsed.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def test_resolve_play_stop_race(bot_id):
|
||||
"""Fire stopBot during the /play call's URL resolve window."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
time.sleep(1)
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
|
||||
# Schedule a stop 150ms into the play call — that lands inside the
|
||||
# provider.getSongUrl await, which is where the race lives.
|
||||
def delayed_stop():
|
||||
time.sleep(0.15)
|
||||
try:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=delayed_stop, daemon=True).start()
|
||||
|
||||
try:
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Give both calls time to settle fully
|
||||
time.sleep(3)
|
||||
b = get_bot(bot_id)
|
||||
# Key invariant: we never want connected=false AND playing=true. That
|
||||
# pair is the exact Bug C symptom and would indicate the resolveAndPlay
|
||||
# post-await check didn't fire.
|
||||
assert not (b["connected"] is False and b["playing"] is True), (
|
||||
f"inconsistent state after race: {b}"
|
||||
)
|
||||
print(
|
||||
f"[PASS] resolveAndPlay stop-race — final state consistent "
|
||||
f"(connected={b['connected']} playing={b['playing']})"
|
||||
)
|
||||
|
||||
|
||||
def test_seek_nan_rejected(bot_id):
|
||||
"""Verify that NaN and Infinity seek positions are rejected at the API
|
||||
layer (instead of poisoning seekOffset)."""
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
|
||||
# Start a real song so there is an active playback to seek against
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
time.sleep(1.2)
|
||||
|
||||
# JSON spec doesn't allow NaN/Infinity literals, but Python's json
|
||||
# encoder emits them as bare tokens when allow_nan=True (the default).
|
||||
# Express's body-parser rejects them as invalid JSON, which itself is
|
||||
# a form of rejection. We additionally verify that sending a string
|
||||
# "NaN" or a negative value is also rejected with a clean 400.
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": -5},
|
||||
)
|
||||
assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": "fifty"},
|
||||
)
|
||||
assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
|
||||
|
||||
# Directly send NaN in raw body (body-parser will likely 400 it)
|
||||
r = requests.post(
|
||||
f"{BASE}/api/player/{bot_id}/seek",
|
||||
data='{"position": NaN}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
|
||||
|
||||
# After the junk attempts, a valid seek still works and the elapsed
|
||||
# time is a finite number (not NaN).
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/seek",
|
||||
method="POST",
|
||||
json={"position": 30},
|
||||
)
|
||||
assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
|
||||
|
||||
elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
|
||||
elapsed = elapsed_resp.json().get("elapsed")
|
||||
assert elapsed is not None and isinstance(elapsed, (int, float)), (
|
||||
f"elapsed should be a number, got {elapsed}"
|
||||
)
|
||||
# Could be exactly 30 or a tiny bit more if a frame has advanced
|
||||
assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
|
||||
print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
|
||||
|
||||
try:
|
||||
test_resolve_play_stop_race(bot_id)
|
||||
test_seek_nan_rejected(bot_id)
|
||||
print("ALL GREEN")
|
||||
finally:
|
||||
if initial:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] connected={get_bot(bot_id)['connected']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Stress-test two bots playing music concurrently on the same TS server.
|
||||
|
||||
Creates two temporary bots (or reuses existing named ones), starts them,
|
||||
plays music on both, and polls /api/bot/ every 2 seconds to detect when
|
||||
(if) either bot disconnects or stops playing. Cleans up on exit.
|
||||
|
||||
Usage:
|
||||
python scripts/test_multibot.py --minutes 3
|
||||
python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
API = "http://localhost:3000"
|
||||
POLL_INTERVAL = 2.0
|
||||
TEST_BOT_NAMES = ("mbtest1", "mbtest2")
|
||||
TEST_BOT_NICKS = ("MBTest1", "MBTest2")
|
||||
QUERIES = ("the mass", "lofi") # one different song per bot
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotSnapshot:
|
||||
t: float
|
||||
connected: bool
|
||||
playing: bool
|
||||
song: str | None
|
||||
|
||||
|
||||
def api_get(path: str):
|
||||
r = requests.get(f"{API}{path}", timeout=5)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_post(path: str, json=None):
|
||||
r = requests.post(f"{API}{path}", json=json, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def api_delete(path: str):
|
||||
r = requests.delete(f"{API}{path}", timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def cleanup_existing(names: tuple[str, ...]) -> None:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
for b in bots:
|
||||
if b["name"] in names:
|
||||
try:
|
||||
api_post(f"/api/player/{b['id']}/stop")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
api_delete(f"/api/bot/{b['id']}")
|
||||
print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] failed to remove {b['name']}: {e}")
|
||||
|
||||
|
||||
def create_bot(name: str, nickname: str, host: str, port: int) -> str:
|
||||
res = api_post(
|
||||
"/api/bot/",
|
||||
json={
|
||||
"name": name,
|
||||
"serverAddress": host,
|
||||
"serverPort": port,
|
||||
"nickname": nickname,
|
||||
"autoStart": False,
|
||||
},
|
||||
)
|
||||
bot_id = res["id"]
|
||||
print(f"[create] {name} -> {bot_id}")
|
||||
return bot_id
|
||||
|
||||
|
||||
def start_bot(bot_id: str) -> None:
|
||||
api_post(f"/api/bot/{bot_id}/start")
|
||||
|
||||
|
||||
def play(bot_id: str, query: str) -> None:
|
||||
api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
|
||||
|
||||
|
||||
def snapshot(bot_id: str, t0: float) -> BotSnapshot:
|
||||
bots = api_get("/api/bot/")["bots"]
|
||||
b = next((x for x in bots if x["id"] == bot_id), None)
|
||||
if not b:
|
||||
return BotSnapshot(time.time() - t0, False, False, None)
|
||||
song = b["currentSong"]["name"] if b.get("currentSong") else None
|
||||
return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
|
||||
|
||||
|
||||
def run(minutes: float, host: str, port: int) -> int:
|
||||
print(f"[setup] duration={minutes}min host={host}:{port}")
|
||||
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
|
||||
bot_ids = [
|
||||
create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
|
||||
create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
|
||||
]
|
||||
|
||||
# Start both, allowing a small stagger to avoid handshake collision
|
||||
for i, bid in enumerate(bot_ids):
|
||||
start_bot(bid)
|
||||
print(f"[start] bot{i+1} started")
|
||||
time.sleep(1.5)
|
||||
|
||||
# Wait until both are connected (or bail after 15s)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
|
||||
if all(bots[b]["connected"] for b in bot_ids):
|
||||
print("[start] both bots connected")
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
print("[fatal] bots did not both come online in 15s")
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
return 2
|
||||
|
||||
# Kick off playback on both
|
||||
for i, bid in enumerate(bot_ids):
|
||||
play(bid, QUERIES[i])
|
||||
print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
|
||||
|
||||
t0 = time.time()
|
||||
end = t0 + minutes * 60
|
||||
first_drop: dict[str, float] = {}
|
||||
last_state: dict[str, BotSnapshot] = {}
|
||||
|
||||
print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
|
||||
print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
|
||||
|
||||
def fmt(snap: BotSnapshot) -> str:
|
||||
flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
|
||||
song = (snap.song or "").replace("\n", " ")[:30]
|
||||
return f"{flag} {song}"
|
||||
|
||||
try:
|
||||
while time.time() < end:
|
||||
snaps = [snapshot(bid, t0) for bid in bot_ids]
|
||||
elapsed = int(time.time() - t0)
|
||||
row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
|
||||
# Only print when state changes or every 10s
|
||||
changed = False
|
||||
for bid, s in zip(bot_ids, snaps):
|
||||
prev = last_state.get(bid)
|
||||
if (prev is None
|
||||
or prev.connected != s.connected
|
||||
or prev.playing != s.playing
|
||||
or prev.song != s.song):
|
||||
changed = True
|
||||
last_state[bid] = s
|
||||
if not s.connected and bid not in first_drop:
|
||||
first_drop[bid] = s.t
|
||||
if changed or elapsed % 10 == 0:
|
||||
print(row)
|
||||
|
||||
# If both stopped playing but are still connected, re-queue the same song
|
||||
for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
|
||||
if s.connected and not s.playing:
|
||||
try:
|
||||
play(bid, QUERIES[i])
|
||||
except Exception as e:
|
||||
print(f"[warn] re-play bot{i+1} failed: {e}")
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except KeyboardInterrupt:
|
||||
print("\n[abort] interrupted")
|
||||
|
||||
# Summary
|
||||
total = time.time() - t0
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Total observed time: {total:.1f}s")
|
||||
for i, bid in enumerate(bot_ids):
|
||||
drop = first_drop.get(bid)
|
||||
if drop is None:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
|
||||
else:
|
||||
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
|
||||
print("=" * 60)
|
||||
|
||||
# Cleanup
|
||||
cleanup_existing(TEST_BOT_NAMES)
|
||||
print("[cleanup] done")
|
||||
|
||||
return 0 if not first_drop else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--minutes", type=float, default=3.0)
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=9987)
|
||||
args = p.parse_args()
|
||||
try:
|
||||
return run(args.minutes, args.host, args.port)
|
||||
except requests.HTTPError as e:
|
||||
print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
// Empirically tests whether the browser UA + Referer headers fix the
|
||||
// connection resets we saw in bot.log against m701/m801.music.126.net.
|
||||
//
|
||||
// Spawns ffmpeg twice against the SAME fresh Netease CDN URL:
|
||||
// A) old args from before the fix (no headers, -reconnect_delay_max 5)
|
||||
// B) new args from after the fix (browser UA + Referer for music.126.net)
|
||||
// and reports bytes received + exit code + stderr-tail for each.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { buildFfmpegArgs } from "../dist/audio/player.js";
|
||||
|
||||
const url = process.argv[2];
|
||||
if (!url) {
|
||||
console.error("usage: node scripts/test_netease_ua_fix.mjs <netease_cdn_url>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const FFMPEG = "ffmpeg";
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
function legacyArgs(u) {
|
||||
return [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-i", u,
|
||||
"-f", "s16le",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-",
|
||||
];
|
||||
}
|
||||
|
||||
function runFfmpeg(label, args) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let bytes = 0;
|
||||
let stderrTail = "";
|
||||
let killed = false;
|
||||
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
bytes += chunk.length;
|
||||
});
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-1500);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
proc.on("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ label, bytes, code, signal, killed, stderrTail });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`URL: ${url}\n`);
|
||||
|
||||
const a = await runFfmpeg("A) legacy args (no UA)", legacyArgs(url));
|
||||
console.log(`[A] code=${a.code} signal=${a.signal} killed=${a.killed} bytes=${a.bytes}`);
|
||||
console.log(` stderr-tail:\n${a.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
|
||||
|
||||
const b = await runFfmpeg("B) fixed args (browser UA + Referer)", buildFfmpegArgs(url, 0));
|
||||
console.log(`[B] code=${b.code} signal=${b.signal} killed=${b.killed} bytes=${b.bytes}`);
|
||||
console.log(` stderr-tail:\n${b.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
|
||||
|
||||
const aFailed = a.bytes === 0 && !a.killed && a.code !== 0;
|
||||
const bWorked = b.bytes > 100_000; // got real audio bytes
|
||||
console.log(`Verdict: legacy ${aFailed ? "FAILED (no bytes, exit code 1)" : "??"} ; fixed ${bWorked ? "WORKED (received audio)" : "??"}`);
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Reproduce / regression-check the player-bar-not-appearing bug.
|
||||
|
||||
Captures the bot's initial playback state and restores it on exit so the
|
||||
test never leaves the user with surprise music or a cleared queue.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
fn = getattr(requests, method.lower())
|
||||
r = fn(f"{BASE}{path}", timeout=10, **kw)
|
||||
r.raise_for_status()
|
||||
return r.json() if r.text else None
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def capture_state(bot_id):
|
||||
b = get_bot(bot_id)
|
||||
return {
|
||||
"playing": b["playing"],
|
||||
"paused": b["paused"],
|
||||
"song": (b["currentSong"] or {}).get("name"),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/")["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial = capture_state(bot_id)
|
||||
print(f"[init] initial state: {initial}")
|
||||
|
||||
try:
|
||||
# Clear slate
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
time.sleep(0.6)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
ctx = browser.new_context()
|
||||
ctx.add_init_script(
|
||||
"""
|
||||
(() => {
|
||||
const OrigWS = window.WebSocket;
|
||||
window.__wsMessages = [];
|
||||
window.WebSocket = function(...args) {
|
||||
const ws = new OrigWS(...args);
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
window.__wsMessages.push({type: d.type, botId: d.botId});
|
||||
} catch(e) {}
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
Object.assign(window.WebSocket, OrigWS);
|
||||
})();
|
||||
"""
|
||||
)
|
||||
page = ctx.new_page()
|
||||
page.goto(BASE)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.8)
|
||||
|
||||
assert page.locator(".player-wrapper").count() == 0, (
|
||||
"player bar should be hidden before playback"
|
||||
)
|
||||
|
||||
# Trigger play via API (simulates any play trigger)
|
||||
api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
|
||||
# Poll for up to 6s to see if player bar appears automatically
|
||||
appeared_at = None
|
||||
for i in range(60):
|
||||
if page.locator(".player-wrapper").count() > 0:
|
||||
appeared_at = i * 0.1
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
if appeared_at is None:
|
||||
msgs = page.evaluate("() => window.__wsMessages")
|
||||
print(f"[FAIL] player bar never appeared; WS msgs: {msgs}")
|
||||
raise AssertionError("player bar did not auto-show on stateChange")
|
||||
print(f"[PASS] player bar appeared after {appeared_at:.1f}s")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
# Restore: stop the "test" song we triggered, then re-apply initial
|
||||
# state as best we can. We can't re-queue the user's previous song,
|
||||
# but we can at least stop ours and leave the bot idle if it was idle.
|
||||
try:
|
||||
api(f"/api/player/{bot_id}/stop", method="POST")
|
||||
except Exception as e:
|
||||
print(f"[warn] failed to stop test song on cleanup: {e}")
|
||||
post = capture_state(bot_id)
|
||||
print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})")
|
||||
if initial["playing"] and initial["song"]:
|
||||
print(
|
||||
f"[note] initial bot was playing {initial['song']!r}; "
|
||||
"this test cannot resume arbitrary tracks — you may need to restart playback"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state.
|
||||
|
||||
Captures the target bot's initial connected state and restores it on exit
|
||||
(including on assertion failure), so running this test never pollutes the
|
||||
user's current bot setup.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def set_connected(bot_id, want: bool) -> None:
|
||||
"""Force the bot into the given connected state via API."""
|
||||
current = get_bot(bot_id)["connected"]
|
||||
if current == want:
|
||||
return
|
||||
endpoint = "start" if want else "stop"
|
||||
requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}")
|
||||
wait_for_connected(bot_id, want)
|
||||
|
||||
|
||||
def main():
|
||||
bots = requests.get(f"{BASE}/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots registered, nothing to test")
|
||||
return
|
||||
target = bots[0]
|
||||
bot_id = target["id"]
|
||||
initial_connected = target["connected"]
|
||||
print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}")
|
||||
|
||||
try:
|
||||
# Force bot disconnected before the test
|
||||
set_connected(bot_id, False)
|
||||
assert not get_bot(bot_id)["connected"], "bot should be disconnected at start"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto(BASE)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(0.6)
|
||||
|
||||
# Open dropdown
|
||||
page.locator(".bot-selector-btn").click()
|
||||
page.wait_for_selector(".bot-power-btn")
|
||||
|
||||
# Click the power button to start
|
||||
page.locator(".bot-power-btn").first.click()
|
||||
print("[ui] clicked power (start)")
|
||||
|
||||
assert wait_for_connected(bot_id, True), "bot should be connected after clicking start"
|
||||
print("[api] bot connected = True")
|
||||
|
||||
# Let UI catch up via WS then re-open the dropdown to re-check class
|
||||
time.sleep(1.0)
|
||||
page.locator(".bot-selector-btn").click() # close
|
||||
time.sleep(0.2)
|
||||
page.locator(".bot-selector-btn").click() # reopen
|
||||
page.wait_for_selector(".bot-power-btn.online", timeout=3000)
|
||||
print("[ui] power button now shows .online class")
|
||||
|
||||
# Click again to stop
|
||||
page.locator(".bot-power-btn.online").first.click()
|
||||
print("[ui] clicked power (stop)")
|
||||
|
||||
assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop"
|
||||
print("[api] bot connected = False")
|
||||
|
||||
print("[PASS] power button toggles bot connection")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
# Always restore the initial state so the test never leaves the bot
|
||||
# in an unexpected place
|
||||
set_connected(bot_id, initial_connected)
|
||||
final = get_bot(bot_id)["connected"]
|
||||
print(f"[restore] bot connected={final} (initial was {initial_connected})")
|
||||
if final != initial_connected:
|
||||
print("[warn] failed to restore initial connected state")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression for the 'connected=False but playing=True' stuck-state bug.
|
||||
|
||||
After rapid disconnect/reconnect, the library could drop the connection
|
||||
(TS3 server anti-flood or a hung handshake). The bot then ended up in an
|
||||
inconsistent state: player.state='playing' but tsClient disconnected.
|
||||
|
||||
This test verifies three fixes:
|
||||
Bug A — startBot() has a 15s timeout instead of hanging forever on a
|
||||
stalled handshake. /start returns a clean 500 instead of blocking.
|
||||
Bug B — play/add/etc commands are rejected when the bot is not connected.
|
||||
Bug C — the tsClient 'disconnected' handler always clears player state,
|
||||
even when connect() never completed (so !this.connected).
|
||||
|
||||
We also sanity-check that the bot recovers (can start a fresh cycle) after
|
||||
a transient failure.
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
|
||||
|
||||
def api(path, method="GET", **kw):
|
||||
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
|
||||
|
||||
|
||||
def get_bot(bot_id):
|
||||
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
|
||||
|
||||
|
||||
def wait_connected(bot_id, want, timeout=15):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if get_bot(bot_id)["connected"] is want:
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
bots = api("/api/bot/").json()["bots"]
|
||||
if not bots:
|
||||
print("[skip] no bots")
|
||||
return
|
||||
bot_id = bots[0]["id"]
|
||||
initial_connected = bots[0]["connected"]
|
||||
print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}")
|
||||
|
||||
try:
|
||||
# Start from a clean slate
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
|
||||
# --- Bug B: play while disconnected must be rejected ---
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "rejection test", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code >= 400, (
|
||||
f"play while disconnected should fail, got {r.status_code} {r.text[:120]}"
|
||||
)
|
||||
assert "not connected" in r.text.lower(), (
|
||||
f"expected 'not connected' error, got: {r.text[:200]}"
|
||||
)
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}"
|
||||
print("[PASS] Bug B — /play rejected while bot disconnected; state untouched")
|
||||
|
||||
# --- Bug C: normal start→play→stop leaves player state clean ---
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
assert r.status_code == 200, f"start failed {r.text[:120]}"
|
||||
assert wait_connected(bot_id, True), "bot did not connect within 15s"
|
||||
|
||||
r = api(
|
||||
f"/api/player/{bot_id}/play",
|
||||
method="POST",
|
||||
json={"query": "the mass", "platform": "netease"},
|
||||
)
|
||||
assert r.status_code == 200, f"play failed {r.text[:120]}"
|
||||
time.sleep(1.2)
|
||||
b = get_bot(bot_id)
|
||||
assert b["connected"] and b["playing"], f"should be connected+playing: {b}"
|
||||
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect"
|
||||
b = get_bot(bot_id)
|
||||
assert not b["playing"], (
|
||||
f"player should have stopped after bot disconnect (Bug C): {b}"
|
||||
)
|
||||
print("[PASS] Bug C — stop clears both connected and playing state")
|
||||
|
||||
# --- Bug A: startBot has a deadline and returns a clean error if connect hangs ---
|
||||
# We can't easily force a hang in-process, but we can sanity-check that
|
||||
# startBot returns promptly (well under the 15s cap) on a normal run.
|
||||
t0 = time.time()
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
elapsed = time.time() - t0
|
||||
assert r.status_code == 200, f"start failed {r.text[:120]}"
|
||||
assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s"
|
||||
assert wait_connected(bot_id, True), "bot did not connect"
|
||||
print(
|
||||
f"[PASS] Bug A — startBot completed in {elapsed:.2f}s "
|
||||
"(deadline is 15s, would throw on hang)"
|
||||
)
|
||||
|
||||
# --- Recovery: after any failure, another start should work ---
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
# Give TS3 server a moment to forget us (anti-flood grace)
|
||||
time.sleep(2)
|
||||
r = api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
assert r.status_code == 200, f"recovery start failed {r.text[:120]}"
|
||||
assert wait_connected(bot_id, True), "bot did not recover"
|
||||
print("[PASS] recovery — bot reconnects cleanly after a cycle")
|
||||
|
||||
print("ALL GREEN")
|
||||
|
||||
finally:
|
||||
if initial_connected:
|
||||
api(f"/api/bot/{bot_id}/start", method="POST")
|
||||
wait_connected(bot_id, True)
|
||||
else:
|
||||
api(f"/api/bot/{bot_id}/stop", method="POST")
|
||||
wait_connected(bot_id, False)
|
||||
print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,588 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
function getHeadersArg(args: string[]): string {
|
||||
const idx = args.indexOf("-headers");
|
||||
if (idx === -1) return "";
|
||||
return args[idx + 1] ?? "";
|
||||
}
|
||||
|
||||
describe("buildFfmpegArgs", () => {
|
||||
it("includes browser User-Agent and Referer for Netease CDN URLs", () => {
|
||||
const url = "http://m701.music.126.net/some/path/song.mp3?vuutv=abc";
|
||||
const args = buildFfmpegArgs(url, 0);
|
||||
const headers = getHeadersArg(args);
|
||||
expect(headers).toContain("User-Agent:");
|
||||
expect(headers).toContain("Mozilla/5.0");
|
||||
expect(headers).toContain("Referer: https://music.163.com/");
|
||||
});
|
||||
|
||||
it("keeps Bilibili Referer + UA for bilibili URLs", () => {
|
||||
const url = "https://upos-sz-mirrorcoso1.bilivideo.com/foo/bar.mp3";
|
||||
const args = buildFfmpegArgs(url, 0);
|
||||
const headers = getHeadersArg(args);
|
||||
expect(headers).toContain("Referer: https://www.bilibili.com");
|
||||
expect(headers).toContain("User-Agent: Mozilla/5.0");
|
||||
});
|
||||
|
||||
it("does not set custom headers for unknown URLs", () => {
|
||||
const url = "https://example.com/song.mp3";
|
||||
const args = buildFfmpegArgs(url, 0);
|
||||
expect(args).not.toContain("-headers");
|
||||
});
|
||||
|
||||
it("includes resilient reconnect flags for all URLs", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||
expect(args).toContain("-reconnect");
|
||||
expect(args).toContain("-reconnect_streamed");
|
||||
expect(args).toContain("-reconnect_delay_max");
|
||||
expect(args).toContain("-reconnect_on_network_error");
|
||||
expect(args).toContain("-reconnect_on_http_error");
|
||||
const idx = args.indexOf("-reconnect_delay_max");
|
||||
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
|
||||
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
|
||||
const idx = args.indexOf("-reconnect_at_eof");
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
expect(args[idx + 1]).toBe("1");
|
||||
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||
});
|
||||
|
||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||
const ssIdx = args.indexOf("-ss");
|
||||
const iIdx = args.indexOf("-i");
|
||||
expect(ssIdx).toBeGreaterThan(-1);
|
||||
expect(args[ssIdx + 1]).toBe("42");
|
||||
expect(ssIdx).toBeLessThan(iIdx);
|
||||
});
|
||||
|
||||
it("does not insert -ss when seekSeconds is 0", () => {
|
||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
|
||||
expect(args).not.toContain("-ss");
|
||||
});
|
||||
|
||||
it("omits HTTP-only flags when input is a local file path", () => {
|
||||
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
|
||||
expect(args).not.toContain("-reconnect");
|
||||
expect(args).not.toContain("-reconnect_at_eof");
|
||||
expect(args).not.toContain("-reconnect_on_network_error");
|
||||
expect(args).not.toContain("-reconnect_on_http_error");
|
||||
expect(args).not.toContain("-headers");
|
||||
expect(args).toContain("-i");
|
||||
expect(args[args.indexOf("-i") + 1]).toBe("C:/temp/song.mp3");
|
||||
});
|
||||
|
||||
it("ends args with the input URL and PCM output spec", () => {
|
||||
const url = "https://example.com/song.mp3";
|
||||
const args = buildFfmpegArgs(url, 0);
|
||||
const iIdx = args.indexOf("-i");
|
||||
expect(args[iIdx + 1]).toBe(url);
|
||||
expect(args).toContain("-f");
|
||||
expect(args).toContain("s16le");
|
||||
expect(args[args.length - 1]).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("volumeToFactor (#84 smooth volume curve)", () => {
|
||||
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
|
||||
expect(volumeToFactor(0)).toBe(0);
|
||||
expect(volumeToFactor(100)).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps out-of-range input", () => {
|
||||
expect(volumeToFactor(-20)).toBe(0);
|
||||
expect(volumeToFactor(150)).toBe(1);
|
||||
});
|
||||
|
||||
it("is strictly monotonic across the whole range (no dead zone)", () => {
|
||||
for (let v = 0; v < 100; v++) {
|
||||
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the old flat 80-99 dead zone", () => {
|
||||
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
|
||||
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
|
||||
});
|
||||
|
||||
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
|
||||
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it("keeps the low range gentle", () => {
|
||||
expect(volumeToFactor(50)).toBeLessThan(0.12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUsePowerShellDownload", () => {
|
||||
const jdymusicUrl =
|
||||
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
|
||||
const newCdnUrl =
|
||||
"http://m801.music.126.net/20260507/abc/jd-musicrep-ts/obj/xyz/song.mp3?vuutv=tok";
|
||||
const ymusicUrl =
|
||||
"http://m801.music.126.net/20260507/abc/ymusic/obj/xyz/song.mp3?vuutv=tok";
|
||||
|
||||
it("returns true for /jdymusic/ URL on win32", () => {
|
||||
expect(shouldUsePowerShellDownload(jdymusicUrl, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for /jdymusic/ URL on linux", () => {
|
||||
expect(shouldUsePowerShellDownload(jdymusicUrl, "linux")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for /jdymusic/ URL on darwin", () => {
|
||||
expect(shouldUsePowerShellDownload(jdymusicUrl, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for new-format /jd-musicrep-ts/ URL on win32", () => {
|
||||
expect(shouldUsePowerShellDownload(newCdnUrl, "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for /ymusic/ URL on win32", () => {
|
||||
expect(shouldUsePowerShellDownload(ymusicUrl, "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unrelated URLs", () => {
|
||||
expect(shouldUsePowerShellDownload("https://example.com/x.mp3", "win32")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupTempDir", () => {
|
||||
it("removes a directory and its contents", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
|
||||
writeFileSync(join(dir, "song.mp3"), "fake-bytes");
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
cleanupTempDir(dir);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not throw when directory does not exist", () => {
|
||||
const missing = join(tmpdir(), "tsbot-test-does-not-exist-xyz");
|
||||
expect(() => cleanupTempDir(missing)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when called twice", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
|
||||
cleanupTempDir(dir);
|
||||
expect(() => cleanupTempDir(dir)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
|
||||
const MAX_EMPTY = 250; // ~5s near-end threshold
|
||||
const MAX_STALL = 3000; // ~60s far-from-end watchdog
|
||||
|
||||
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
|
||||
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
|
||||
// This is the core regression: a brief underrun mid-song must not end the track.
|
||||
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||
});
|
||||
|
||||
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
|
||||
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
|
||||
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||
});
|
||||
|
||||
it("never ends before any threshold", () => {
|
||||
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal stub: AudioPlayer only calls debug/info/warn/error; child() returns self.
|
||||
const silentLogger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
fatal() {},
|
||||
trace() {},
|
||||
child() {
|
||||
return silentLogger;
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
// A readable we fully control: no underlying source; we push PCM manually and
|
||||
// keep it open (never push(null)) to model the long-lived go-librespot sidecar.
|
||||
function openPcmReadable(): Readable {
|
||||
return new Readable({ read() {} });
|
||||
}
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise<void>((r) => setTimeout(r, ms));
|
||||
const FRAME_BYTES = 3840; // PCM_FRAME_BYTES: 960 samples * 2ch * 2 bytes @48k s16le
|
||||
|
||||
describe("AudioPlayer external-PCM mode (playPcmStream)", () => {
|
||||
it("emits Opus 'frame' events from the external PCM stream without spawning ffmpeg", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const frames: Buffer[] = [];
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 10)); // ~10 frames of PCM
|
||||
|
||||
await wait(150); // ~7 frame ticks at 20ms
|
||||
|
||||
expect(player.getState()).toBe("playing");
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(Buffer.isBuffer(frames[0])).toBe(true);
|
||||
player.stop();
|
||||
});
|
||||
|
||||
it("does NOT emit 'trackEnd' on underrun while external (stream stays open)", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
let ended = 0;
|
||||
const frames: Buffer[] = [];
|
||||
player.on("trackEnd", () => ended++);
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 2)); // only 2 frames, then underrun
|
||||
|
||||
await wait(200); // long after those 2 frames have drained
|
||||
|
||||
// In the url path, ffmpeg===null + empty buffer would fire trackEnd; here it must not.
|
||||
expect(ended).toBe(0);
|
||||
// Silence frames keep the 20ms timeline alive -> more than the 2 fed frames emitted.
|
||||
expect(frames.length).toBeGreaterThan(2);
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
|
||||
// CORRECTION C2 (c): stop() DETACHES the shared readable — it must NOT be destroyed
|
||||
// (destroying the sidecar's long-lived ffmpeg stdout would kill it for every future
|
||||
// track). The sessionId bump + listener removal fence stale PCM out of pcmBuffer.
|
||||
it("stop() detaches external mode without destroying the readable, and fences via sessionId", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const frames: Buffer[] = [];
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
expect(stream.listenerCount("data")).toBe(1);
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 5));
|
||||
await wait(80);
|
||||
|
||||
player.stop();
|
||||
expect(player.getState()).toBe("idle");
|
||||
// C2: the shared sidecar stream must NOT be destroyed by teardown.
|
||||
expect(stream.destroyed).toBe(false);
|
||||
// Player's listeners are removed on detach (data/end/error).
|
||||
expect(stream.listenerCount("data")).toBe(0);
|
||||
expect(stream.listenerCount("end")).toBe(0);
|
||||
expect(stream.listenerCount("error")).toBe(0);
|
||||
|
||||
const countAtStop = frames.length;
|
||||
// sessionId fence + detached listeners: PCM pushed after stop must not
|
||||
// resurrect the timeline or re-feed pcmBuffer.
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 5));
|
||||
await wait(80);
|
||||
expect(frames.length).toBe(countAtStop);
|
||||
});
|
||||
|
||||
// CORRECTION C2 (a): a gapless track change is driven by the sidecar pushing LATER
|
||||
// PCM over the SAME already-attached stream. The player must NOT detach/re-attach
|
||||
// (no second playPcmStream) — one persistent data listener serves every track.
|
||||
it("(C2-a) feeds a later chunk over the SAME single attachment — gapless track change, no re-attach", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const frames: Buffer[] = [];
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
expect(stream.listenerCount("data")).toBe(1); // attached exactly once
|
||||
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // "track 1" PCM
|
||||
await wait(120);
|
||||
const afterFirst = frames.length;
|
||||
expect(afterFirst).toBeGreaterThan(0);
|
||||
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // sidecar seamlessly rolls into "track 2"
|
||||
await wait(120);
|
||||
expect(frames.length).toBeGreaterThan(afterFirst);
|
||||
|
||||
// Still exactly ONE listener — no detach/re-attach across the handoff.
|
||||
expect(stream.listenerCount("data")).toBe(1);
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
|
||||
// CORRECTION C2 (b): a second playPcmStream detaches the first (NOT destroyed, and it
|
||||
// stops feeding pcmBuffer) and attaches the second.
|
||||
it("(C2-b) a second playPcmStream detaches the first (not destroyed, stops feeding) and attaches the second", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const frames: Buffer[] = [];
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
const first = openPcmReadable();
|
||||
player.playPcmStream(first, {});
|
||||
first.push(Buffer.alloc(FRAME_BYTES * 4));
|
||||
await wait(120);
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(first.listenerCount("data")).toBe(1);
|
||||
|
||||
const second = openPcmReadable();
|
||||
player.playPcmStream(second, {}); // fences + detaches `first`, attaches `second`
|
||||
|
||||
// C2: `first` is DETACHED, not destroyed.
|
||||
expect(first.destroyed).toBe(false);
|
||||
// `first` no longer feeds pcmBuffer — its data listener was removed.
|
||||
expect(first.listenerCount("data")).toBe(0);
|
||||
// `second` is now the attached source.
|
||||
expect(second.listenerCount("data")).toBe(1);
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
|
||||
it("fires onExternalEnd when the readable ends (drives controller-based advance)", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
let endedCb = 0;
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, { onExternalEnd: () => endedCb++ });
|
||||
stream.push(Buffer.alloc(FRAME_BYTES));
|
||||
await wait(40);
|
||||
stream.push(null); // end-of-stream
|
||||
await wait(40);
|
||||
|
||||
expect(endedCb).toBe(1);
|
||||
player.stop();
|
||||
});
|
||||
|
||||
it("seek() is a local no-op in external mode (never respawns ffmpeg on a spotify sentinel)", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 3));
|
||||
await wait(40);
|
||||
|
||||
expect(() => player.seek(30)).not.toThrow();
|
||||
// Still external, still playing — no url-ffmpeg respawn, state unchanged.
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
|
||||
it("isExternalActive() is false initially, true after playPcmStream, false after stop()", () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
// Idle: never attached.
|
||||
expect(player.isExternalActive()).toBe(false);
|
||||
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
// Attached to the external sidecar stream.
|
||||
expect(player.isExternalActive()).toBe(true);
|
||||
|
||||
player.stop();
|
||||
// Detached again — the orchestrator uses this to know it must re-attach.
|
||||
expect(player.isExternalActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const stream = openPcmReadable();
|
||||
player.playPcmStream(stream, {});
|
||||
stream.push(Buffer.alloc(FRAME_BYTES * 3));
|
||||
await wait(40);
|
||||
|
||||
player.pause();
|
||||
expect(player.getState()).toBe("paused");
|
||||
player.resume();
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
|
||||
// CORRECTION C1 (whole-branch): mixed queue [spotifyA, neteaseB, spotifyC].
|
||||
// Advancing A -> B (a NON-spotify track) calls stop(), whose detachExternalStream()
|
||||
// PAUSES the backend's long-lived SHARED readable (state.flowing = false). When the
|
||||
// LATER spotify track C reuses the SAME backend, the orchestrator re-attaches that
|
||||
// SAME readable via playPcmStream(). Node's Readable.on('data') only auto-resumes
|
||||
// when flowing !== false, so without an explicit resume() the shared stream stays
|
||||
// paused, onData never fires, pcmBuffer stays empty, and C plays only silence frames.
|
||||
// Regression: after re-attach the shared stream MUST be flowing again and real PCM
|
||||
// MUST reach the player.
|
||||
it("(C1) resumes a re-attached, previously-paused SHARED stream so a later Spotify track isn't silent", async () => {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const frames: Buffer[] = [];
|
||||
player.on("frame", (f) => frames.push(f));
|
||||
|
||||
// The backend's long-lived, SHARED readable, reused across every track.
|
||||
const shared = openPcmReadable();
|
||||
|
||||
// --- Spotify track A: first attach (auto-resumes, flowing was null !== false).
|
||||
player.playPcmStream(shared, {});
|
||||
shared.push(Buffer.alloc(FRAME_BYTES * 4));
|
||||
await wait(120);
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
|
||||
// --- Advance to a NON-spotify track (neteaseB): play(url) begins with stop(),
|
||||
// which detaches AND pauses the shared stream (state.flowing = false).
|
||||
player.stop();
|
||||
expect(shared.isPaused()).toBe(true); // shared stream is now paused
|
||||
expect(player.getState()).toBe("idle");
|
||||
|
||||
// --- Spotify track C reuses the SAME backend: isExternalActive() is false so the
|
||||
// orchestrator re-attaches the SAME (paused) shared readable.
|
||||
expect(player.isExternalActive()).toBe(false);
|
||||
|
||||
// Spy on the shared stream to observe whether real PCM actually flows to the
|
||||
// player. Adding a 'data' listener while flowing===false does NOT resume it
|
||||
// (Node semantics), so this spy cannot mask the bug — pre-fix it stays at 0.
|
||||
let spyBytes = 0;
|
||||
shared.on("data", (c: Buffer) => {
|
||||
spyBytes += c.length;
|
||||
});
|
||||
|
||||
player.playPcmStream(shared, {}); // re-attach the SAME shared readable
|
||||
|
||||
// The re-attached stream must be flowing again, or track C is silent.
|
||||
expect(shared.isPaused()).toBe(false);
|
||||
|
||||
shared.push(Buffer.alloc(FRAME_BYTES * 4)); // "track C" PCM
|
||||
await wait(120);
|
||||
|
||||
// onData must have run (real PCM reached the player), not just silence frames.
|
||||
expect(spyBytes).toBeGreaterThan(0);
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
});
|
||||
});
|
||||
|
||||
// R3-4: the 20ms frame loop keeps running while paused (so a live-but-silent
|
||||
// stream can refill on resume). But the stall/EOF end-detection branches MUST
|
||||
// only run while state==="playing" — otherwise pausing a stalled or
|
||||
// unknown-duration stream still accumulates emptyFrameAttempts and auto-emits
|
||||
// trackEnd (~5s later), making the controller skip the paused track.
|
||||
//
|
||||
// These tests drive the real url-path frame loop (this.ffmpeg !== null, NOT
|
||||
// external mode), which cannot be exercised via playPcmStream (that sets
|
||||
// externalMode and suppresses both branches). We inject a fake live ffmpeg +
|
||||
// an empty pcmBuffer (a stream that stays alive but never yields a full PCM
|
||||
// frame) and run the actual startFrameLoop() under fake timers. `performance`
|
||||
// is faked in lockstep with the timer clock so each tick advances a real 20ms,
|
||||
// letting us cheaply cross MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s) deterministically.
|
||||
describe("AudioPlayer stall/EOF end-detection is gated on playing state (R3-4)", () => {
|
||||
// Fake `performance` in lockstep with the timer clock so each advanced 20ms is
|
||||
// a real frame tick (the loop computes its delay from performance.now()).
|
||||
const FAKE_TIMER_OPTS: Parameters<typeof vi.useFakeTimers>[0] = {
|
||||
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
|
||||
};
|
||||
|
||||
// A player primed as "playing" with a LIVE ffmpeg that never produces a full
|
||||
// PCM frame (unknown duration -> isNearEnd forced true). startFrameLoop() runs
|
||||
// the genuine loop; no real process is spawned (fake ffmpeg has no pid, so the
|
||||
// end path never touches forceCleanup/process.kill).
|
||||
function makeStalledPlaying(): AudioPlayer {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const p = player as unknown as {
|
||||
ffmpeg: unknown;
|
||||
currentSongDuration: number;
|
||||
pcmBuffer: Buffer;
|
||||
emptyFrameAttempts: number;
|
||||
framesPlayed: number;
|
||||
state: string;
|
||||
startFrameLoop(): void;
|
||||
};
|
||||
p.ffmpeg = { pid: undefined }; // live ffmpeg, but delivers no PCM
|
||||
p.currentSongDuration = 0; // unknown duration -> isNearEnd === true
|
||||
p.pcmBuffer = Buffer.alloc(0); // always < one PCM frame
|
||||
p.emptyFrameAttempts = 0;
|
||||
p.framesPlayed = 0;
|
||||
p.state = "playing";
|
||||
p.startFrameLoop();
|
||||
return player;
|
||||
}
|
||||
|
||||
it("does NOT emit trackEnd (and stays paused) when a stalled unknown-duration stream is paused past the stall threshold", () => {
|
||||
vi.useFakeTimers(FAKE_TIMER_OPTS);
|
||||
try {
|
||||
const player = makeStalledPlaying();
|
||||
let ended = 0;
|
||||
player.on("trackEnd", () => ended++);
|
||||
|
||||
player.pause();
|
||||
expect(player.getState()).toBe("paused");
|
||||
|
||||
// Advance well past MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s): ~300 ticks.
|
||||
vi.advanceTimersByTime(20 * 300);
|
||||
|
||||
expect(ended).toBe(0);
|
||||
expect(player.getState()).toBe("paused");
|
||||
player.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("STILL emits trackEnd when the SAME stalled unknown-duration stream is left playing (dead-stream recovery #89 preserved)", () => {
|
||||
vi.useFakeTimers(FAKE_TIMER_OPTS);
|
||||
try {
|
||||
const player = makeStalledPlaying(); // stays "playing"
|
||||
let ended = 0;
|
||||
player.on("trackEnd", () => ended++);
|
||||
|
||||
vi.advanceTimersByTime(20 * 300); // cross the 250-tick stall threshold
|
||||
|
||||
expect(ended).toBe(1);
|
||||
expect(player.getState()).toBe("idle");
|
||||
player.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not spuriously end after a brief pause+resume on a healthy stream", () => {
|
||||
vi.useFakeTimers(FAKE_TIMER_OPTS);
|
||||
try {
|
||||
const player = new AudioPlayer(silentLogger);
|
||||
const p = player as unknown as {
|
||||
ffmpeg: unknown;
|
||||
currentSongDuration: number;
|
||||
pcmBuffer: Buffer;
|
||||
emptyFrameAttempts: number;
|
||||
framesPlayed: number;
|
||||
state: string;
|
||||
startFrameLoop(): void;
|
||||
};
|
||||
// Healthy: a live ffmpeg with a large buffered runway that never drains
|
||||
// empty across the ticks below, so no underrun is ever seen.
|
||||
p.ffmpeg = { pid: undefined };
|
||||
p.currentSongDuration = 0;
|
||||
p.pcmBuffer = Buffer.alloc(FRAME_BYTES * 400);
|
||||
p.emptyFrameAttempts = 0;
|
||||
p.framesPlayed = 0;
|
||||
p.state = "playing";
|
||||
p.startFrameLoop();
|
||||
|
||||
let ended = 0;
|
||||
player.on("trackEnd", () => ended++);
|
||||
|
||||
vi.advanceTimersByTime(20 * 5); // play a few frames
|
||||
player.pause();
|
||||
vi.advanceTimersByTime(20 * 100); // brief pause (buffer NOT drained while paused)
|
||||
player.resume();
|
||||
expect(player.getState()).toBe("playing");
|
||||
vi.advanceTimersByTime(20 * 100); // resume; still plenty of runway
|
||||
|
||||
expect(ended).toBe(0);
|
||||
expect(player.getState()).toBe("playing");
|
||||
player.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
+599
-189
@@ -1,15 +1,19 @@
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createRequire } from "node:module";
|
||||
import { accessSync, chmodSync, constants } from "node:fs";
|
||||
import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
|
||||
const require = createRequire(import.meta.url);
|
||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||
|
||||
/** Ensure the given binary has execute permission. */
|
||||
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
|
||||
const globalActivePids = new Set<number>();
|
||||
|
||||
function isExecutable(binPath: string): boolean {
|
||||
try {
|
||||
accessSync(binPath, constants.X_OK);
|
||||
@@ -25,7 +29,6 @@ function isExecutable(binPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Test if an ffmpeg binary actually works by running -version. */
|
||||
function ffmpegWorks(bin: string): boolean {
|
||||
try {
|
||||
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
|
||||
@@ -35,32 +38,114 @@ function ffmpegWorks(bin: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
|
||||
const resolvedFfmpeg: string = (() => {
|
||||
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
|
||||
// it may return a Windows .exe path if node_modules was copied cross-platform.
|
||||
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
|
||||
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
|
||||
const onWindows = process.platform === "win32";
|
||||
|
||||
// Only try bundled binary if platform matches
|
||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
||||
return ffmpegPath;
|
||||
}
|
||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
|
||||
}
|
||||
// Fall back to system ffmpeg
|
||||
if (ffmpegWorks("ffmpeg")) {
|
||||
return "ffmpeg";
|
||||
}
|
||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
||||
return "ffmpeg";
|
||||
})();
|
||||
|
||||
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
|
||||
function getFfmpegCommand(): string {
|
||||
export function getFfmpegCommand(): string {
|
||||
return resolvedFfmpeg;
|
||||
}
|
||||
|
||||
const BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
// Old jdymusic CDN paths (e.g. /jdymusic/obj/...) RST direct Node-stack
|
||||
// requests on Windows; same URL works when fetched via WinHTTP. Empirically,
|
||||
// /jd-musicrep-ts/ and /ymusic/ paths do not have this restriction.
|
||||
export function shouldUsePowerShellDownload(
|
||||
url: string,
|
||||
platform: string = process.platform,
|
||||
): boolean {
|
||||
return platform === "win32" && url.includes("/jdymusic/");
|
||||
}
|
||||
|
||||
export function cleanupTempDir(dir: string): void {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
const args: string[] = [];
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
|
||||
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
|
||||
args.push(
|
||||
"-headers",
|
||||
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||
);
|
||||
} else if (isHttp && (url.includes("music.126.net") || url.includes("music.163.com"))) {
|
||||
args.push(
|
||||
"-headers",
|
||||
`Referer: https://music.163.com/\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isHttp) {
|
||||
args.push(
|
||||
"-reconnect", "1",
|
||||
// Long B站 streams sit on a CDN whose session/token can close the
|
||||
// connection mid-file (premature EOF). Without this, FFmpeg treats that
|
||||
// EOF as end-of-input and stops ~partway through (see #89); with it, it
|
||||
// re-issues a Range request from the current offset to finish the stream.
|
||||
"-reconnect_at_eof", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "30",
|
||||
"-reconnect_on_network_error", "1",
|
||||
"-reconnect_on_http_error", "4xx,5xx",
|
||||
);
|
||||
}
|
||||
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to end the current track when FFmpeg is still alive but has
|
||||
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
|
||||
*
|
||||
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
|
||||
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
|
||||
* so a transient buffer underrun on a healthy stream does NOT cause a false
|
||||
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
|
||||
* expired mid-playback, #89) still recovers by advancing instead of going
|
||||
* permanently silent.
|
||||
*/
|
||||
export function shouldEndOnStall(
|
||||
emptyAttempts: number,
|
||||
isNearEnd: boolean,
|
||||
maxEmptyAttempts: number,
|
||||
maxStallAttempts: number,
|
||||
): boolean {
|
||||
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
|
||||
if (emptyAttempts >= maxStallAttempts) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
|
||||
*
|
||||
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
|
||||
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
|
||||
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
|
||||
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
|
||||
* the low end gentle but ramps smoothly toward full loudness near the top, so the
|
||||
* slider feels proportional with no dead zone and no discontinuity at 100.
|
||||
*/
|
||||
export function volumeToFactor(volume: number): number {
|
||||
const x = Math.max(0, Math.min(100, volume)) / 100;
|
||||
return 0.2 * x + 0.8 * Math.pow(x, 8);
|
||||
}
|
||||
|
||||
export interface PlayerEvents {
|
||||
frame: (opusFrame: Buffer) => void;
|
||||
trackEnd: () => void;
|
||||
@@ -82,14 +167,42 @@ export class AudioPlayer extends EventEmitter {
|
||||
private nextFrameTime = 0;
|
||||
private currentUrl = "";
|
||||
private seekOffset = 0;
|
||||
private framesPlayed = 0; // ground truth: number of 20ms frames sent
|
||||
private framesPlayed = 0;
|
||||
private sessionId = 0;
|
||||
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
||||
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
||||
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
|
||||
private static readonly BUFFER_LOW_WATER = 256 * 1024;
|
||||
private ffmpegPaused = false;
|
||||
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
|
||||
private spawnFailed = false;
|
||||
private consecutiveFailures = 0;
|
||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
||||
private healthyFrames = 0;
|
||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||
private downloader: ChildProcess | null = null;
|
||||
private currentTempDir: string | null = null;
|
||||
private emptyFrameAttempts = 0;
|
||||
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
||||
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
|
||||
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
|
||||
// as dead and advance instead of staying silent forever. Set high so a normal
|
||||
// transient underrun never trips it.
|
||||
private static readonly MAX_STALL_ATTEMPTS = 3000;
|
||||
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
||||
|
||||
// --- External PCM mode (Stage 2: go-librespot Spotify sidecar) ---
|
||||
// When true, PCM arrives from a long-lived external Readable instead of a
|
||||
// per-URL ffmpeg: this.ffmpeg stays null, and the underrun-driven trackEnd
|
||||
// branches are suppressed (advance is driven by the controller, not EOF).
|
||||
//
|
||||
// CORRECTION C2: externalStream is the backend's LONG-LIVED, SHARED ffmpeg
|
||||
// stdout (one stream reused across every track). Teardown must DETACH (remove
|
||||
// the listeners we added + pause), never destroy it. We keep references to the
|
||||
// exact handler functions so detach can removeListener precisely.
|
||||
private externalMode = false;
|
||||
private externalStream: Readable | null = null;
|
||||
private onExternalEnd: (() => void) | null = null;
|
||||
private externalDataHandler: ((chunk: Buffer) => void) | null = null;
|
||||
private externalEndHandler: (() => void) | null = null;
|
||||
private externalErrorHandler: ((err: Error) => void) | null = null;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super();
|
||||
@@ -97,113 +210,381 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
play(url: string, seekSeconds = 0): void {
|
||||
play(url: string, seekSeconds = 0, songDuration = 0): void {
|
||||
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||
this.stop();
|
||||
this.sessionId++;
|
||||
const playSessionId = this.sessionId;
|
||||
|
||||
const currentSessionId = this.sessionId;
|
||||
this.currentUrl = url;
|
||||
this.seekOffset = seekSeconds;
|
||||
this.framesPlayed = 0;
|
||||
this.healthyFrames = 0;
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
this.emptyFrameAttempts = 0;
|
||||
this.currentSongDuration = songDuration;
|
||||
|
||||
// Prevent rapid-fire spawn attempts when ffmpeg is broken
|
||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||
this.logger.error(
|
||||
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
|
||||
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
|
||||
);
|
||||
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||
this.state = "idle";
|
||||
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
|
||||
this.emit("error", new Error("ffmpeg unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
// BiliBili CDN requires Referer header for audio playback
|
||||
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
||||
args.push(
|
||||
"-headers",
|
||||
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
|
||||
);
|
||||
if (shouldUsePowerShellDownload(url)) {
|
||||
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
);
|
||||
if (seekSeconds > 0) {
|
||||
args.push("-ss", String(seekSeconds));
|
||||
}
|
||||
args.push(
|
||||
"-i", url,
|
||||
"-f", "s16le",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-acodec", "pcm_s16le",
|
||||
"-",
|
||||
);
|
||||
const args = buildFfmpegArgs(url, seekSeconds);
|
||||
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
globalActivePids.add(currentPid);
|
||||
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
|
||||
}
|
||||
|
||||
// Prevent stream errors from crashing the process
|
||||
this.ffmpeg.stdout!.on("error", (err) => {
|
||||
this.logger.warn({ err }, "FFmpeg stdout error");
|
||||
});
|
||||
this.ffmpeg.stderr!.on("error", (err) => {
|
||||
this.logger.warn({ err }, "FFmpeg stderr error");
|
||||
});
|
||||
|
||||
let gotFirstData = false;
|
||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (!gotFirstData) {
|
||||
gotFirstData = true;
|
||||
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
|
||||
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
|
||||
if (this.sessionId !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||
// Backpressure: pause FFmpeg stdout when buffer is too large
|
||||
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
||||
this.ffmpeg.stdout.pause();
|
||||
this.ffmpegPaused = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
|
||||
if (this.sessionId === playSessionId) {
|
||||
this.ffmpeg = null; // Signal frame loop that no more data is coming
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
|
||||
// 只有当前会话的进程结束才置空变量
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.ffmpeg = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
this.logger.error({ err }, "FFmpeg error");
|
||||
if (this.sessionId === playSessionId) {
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Log FFmpeg stderr at info level for debugging playback issues
|
||||
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
|
||||
const msg = data.toString().trimEnd();
|
||||
// Log important FFmpeg messages at info level
|
||||
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
|
||||
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
|
||||
} else {
|
||||
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
|
||||
this.state = "playing";
|
||||
this.startFrameLoop();
|
||||
}
|
||||
|
||||
private playViaPowerShellDownload(url: string, seekSeconds: number, sessionId: number): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-"));
|
||||
const tempFile = join(tempDir, "song.audio");
|
||||
this.currentTempDir = tempDir;
|
||||
|
||||
const psScript = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$ProgressPreference = 'SilentlyContinue'",
|
||||
"$wc = New-Object System.Net.WebClient",
|
||||
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
|
||||
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
|
||||
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
|
||||
].join("; ");
|
||||
|
||||
this.logger.debug({ sessionId, tempFile }, "Downloading via PowerShell (jdymusic CDN)");
|
||||
|
||||
const ps = spawn(
|
||||
"powershell",
|
||||
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
DL_URL: url,
|
||||
DL_OUT: tempFile,
|
||||
DL_UA: BROWSER_UA,
|
||||
DL_REFERER: "https://music.163.com/",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
this.downloader = ps;
|
||||
|
||||
let stderrTail = "";
|
||||
ps.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-500);
|
||||
});
|
||||
|
||||
ps.on("exit", (code, signal) => {
|
||||
if (this.sessionId !== sessionId) {
|
||||
cleanupTempDir(tempDir);
|
||||
return;
|
||||
}
|
||||
this.downloader = null;
|
||||
if (code !== 0) {
|
||||
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.state = "idle";
|
||||
cleanupTempDir(tempDir);
|
||||
this.currentTempDir = null;
|
||||
this.emit("error", new Error(`PowerShell download exited ${code}`));
|
||||
return;
|
||||
}
|
||||
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
|
||||
});
|
||||
|
||||
ps.on("error", (err) => {
|
||||
if (this.sessionId !== sessionId) return;
|
||||
this.downloader = null;
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
cleanupTempDir(tempDir);
|
||||
this.currentTempDir = null;
|
||||
this.emit("error", err);
|
||||
});
|
||||
|
||||
// Mark playing but DO NOT start the frame loop here — the loop's
|
||||
// "no ffmpeg + empty buffer → trackEnd" branch would fire on the very
|
||||
// first tick, before the PowerShell download even completes. The
|
||||
// frame loop is started inside spawnFfmpegFromFile() once ffmpeg is
|
||||
// alive and producing PCM.
|
||||
this.state = "playing";
|
||||
}
|
||||
|
||||
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
|
||||
if (this.sessionId !== sessionId) {
|
||||
if (this.currentTempDir) {
|
||||
cleanupTempDir(this.currentTempDir);
|
||||
this.currentTempDir = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const args = buildFfmpegArgs(tempFile, seekSeconds);
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
globalActivePids.add(currentPid);
|
||||
this.logger.debug({ pid: currentPid, sessionId }, "FFmpeg spawned (from temp file)");
|
||||
}
|
||||
const tempDirToCleanup = this.currentTempDir;
|
||||
|
||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (this.sessionId !== sessionId) return;
|
||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
||||
this.ffmpeg.stdout.pause();
|
||||
this.ffmpegPaused = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
if (this.sessionId === sessionId) {
|
||||
this.ffmpeg = null;
|
||||
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
|
||||
}
|
||||
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
if (this.sessionId === sessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Now that ffmpeg is producing PCM, run the frame loop.
|
||||
this.startFrameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* External-PCM mode (Stage 2 go-librespot Spotify sidecar).
|
||||
*
|
||||
* Feeds an already-normalized 48kHz/s16le/stereo PCM Readable (the
|
||||
* go-librespot FIFO -> ffmpeg output) straight into the existing pcmBuffer +
|
||||
* 20ms frame loop + Opus encoder + "frame" emission, WITHOUT spawning a
|
||||
* per-URL ffmpeg. The url play() path is left completely untouched.
|
||||
*
|
||||
* Track advance is NOT driven by buffer underrun here (the sidecar stream is
|
||||
* continuous and never EOFs per song); the caller drives advance via the
|
||||
* SpotifyController "trackEnded" WebSocket event. onExternalEnd fires only if
|
||||
* the underlying readable itself ends or errors.
|
||||
*
|
||||
* CORRECTION C2: the readable is the backend's long-lived, SHARED ffmpeg
|
||||
* stdout reused across every track — a gapless track change is just LATER PCM
|
||||
* on this SAME already-attached stream (no re-attach). Teardown DETACHES
|
||||
* (removes our listeners + pauses); it never destroys the shared stream.
|
||||
*/
|
||||
playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void } = {}): void {
|
||||
// 1. Fence current playback: stop() bumps sessionId, clears pcmBuffer, kills
|
||||
// any ffmpeg, and DETACHES (never destroys) any prior external stream.
|
||||
this.stop();
|
||||
|
||||
const currentSessionId = this.sessionId;
|
||||
this.externalMode = true;
|
||||
this.externalStream = readable;
|
||||
this.onExternalEnd = opts.onExternalEnd ?? null;
|
||||
// Leave this.ffmpeg = null; clear currentUrl so seek() cannot respawn ffmpeg.
|
||||
this.currentUrl = "";
|
||||
this.seekOffset = 0;
|
||||
this.framesPlayed = 0;
|
||||
this.healthyFrames = 0;
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
this.emptyFrameAttempts = 0;
|
||||
this.currentSongDuration = 0;
|
||||
|
||||
// Same ingestion + high-water backpressure as the ffmpeg.stdout handler,
|
||||
// but pausing the Readable instead of ffmpeg.stdout. sessionId-guarded so
|
||||
// stale sidecar PCM can't leak into a new track after stop()/skip. Handler
|
||||
// refs are stored so detach can remove exactly these listeners (C2).
|
||||
const onData = (chunk: Buffer): void => {
|
||||
if (this.sessionId !== currentSessionId) return;
|
||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||
if (
|
||||
this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER &&
|
||||
!this.ffmpegPaused &&
|
||||
this.externalStream === readable
|
||||
) {
|
||||
readable.pause();
|
||||
this.ffmpegPaused = true;
|
||||
}
|
||||
};
|
||||
const onEnd = (): void => {
|
||||
if (this.sessionId !== currentSessionId) return;
|
||||
this.onExternalEnd?.();
|
||||
};
|
||||
const onError = (err: Error): void => {
|
||||
if (this.sessionId !== currentSessionId) return;
|
||||
this.logger.warn({ err }, "External PCM stream error");
|
||||
this.onExternalEnd?.();
|
||||
};
|
||||
|
||||
this.externalDataHandler = onData;
|
||||
this.externalEndHandler = onEnd;
|
||||
this.externalErrorHandler = onError;
|
||||
|
||||
readable.on("data", onData);
|
||||
readable.on("end", onEnd);
|
||||
readable.on("error", onError);
|
||||
|
||||
// CORRECTION C1: explicitly resume a re-attached, previously-paused Readable.
|
||||
// The backend's SHARED stdout is reused across every track; a prior non-spotify
|
||||
// advance ran stop() -> detachExternalStream() which pause()d it (state.flowing =
|
||||
// false). Node's Readable.on('data') only auto-resumes when flowing !== false, so
|
||||
// re-attaching a paused stream would leave it stuck: onData never fires, pcmBuffer
|
||||
// stays empty, and a later Spotify track plays only silence. resume() is safe/
|
||||
// idempotent on a first attach (never-paused/already-flowing) stream.
|
||||
readable.resume();
|
||||
|
||||
this.state = "playing";
|
||||
this.startFrameLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORRECTION C2: DETACH, never destroy. The external readable is the backend's
|
||||
* long-lived, SHARED ffmpeg stdout reused across every track; destroying it
|
||||
* would kill the sidecar pipe for all future tracks. Remove only the listeners
|
||||
* WE added and pause the flow so stale PCM stops landing in pcmBuffer, then
|
||||
* clear the external-mode state.
|
||||
*/
|
||||
private detachExternalStream(): void {
|
||||
const stream = this.externalStream;
|
||||
if (stream) {
|
||||
if (this.externalDataHandler) stream.off("data", this.externalDataHandler);
|
||||
if (this.externalEndHandler) stream.off("end", this.externalEndHandler);
|
||||
if (this.externalErrorHandler) stream.off("error", this.externalErrorHandler);
|
||||
try {
|
||||
stream.pause();
|
||||
} catch {
|
||||
/* best-effort: never destroy the shared sidecar stream */
|
||||
}
|
||||
}
|
||||
this.externalDataHandler = null;
|
||||
this.externalEndHandler = null;
|
||||
this.externalErrorHandler = null;
|
||||
this.externalStream = null;
|
||||
this.externalMode = false;
|
||||
this.onExternalEnd = null;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// 3. 递增 ID 是最有效的逻辑“隔离墙”
|
||||
this.sessionId++;
|
||||
this.frameLoopRunning = false;
|
||||
|
||||
// 立即清空缓冲区,确保切歌瞬间静音 (
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
const pidToKill = procToKill.pid;
|
||||
this.ffmpeg = null;
|
||||
|
||||
if (pidToKill) {
|
||||
this.forceCleanup(procToKill, pidToKill);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.downloader) {
|
||||
const ps = this.downloader;
|
||||
this.downloader = null;
|
||||
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
if (this.currentTempDir) {
|
||||
cleanupTempDir(this.currentTempDir);
|
||||
this.currentTempDir = null;
|
||||
}
|
||||
|
||||
// CORRECTION C2: tear down external mode by DETACHING (remove our listeners +
|
||||
// pause) — never destroy the shared, long-lived sidecar stream. The
|
||||
// sessionId++ above already fences the external data/end/error handlers.
|
||||
this.detachExternalStream();
|
||||
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
this.state = "idle";
|
||||
this.currentUrl = "";
|
||||
this.seekOffset = 0;
|
||||
this.framesPlayed = 0;
|
||||
this.healthyFrames = 0;
|
||||
}
|
||||
|
||||
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||
if (!globalActivePids.has(pid)) return;
|
||||
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch (e) {
|
||||
} finally {
|
||||
globalActivePids.delete(pid);
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
proc.unref();
|
||||
proc.once("exit", () => {
|
||||
clearTimeout(killTimeout);
|
||||
globalActivePids.delete(pid);
|
||||
});
|
||||
}
|
||||
|
||||
private startFrameLoop(): void {
|
||||
if (this.frameLoopRunning) return;
|
||||
this.frameLoopRunning = true;
|
||||
@@ -213,53 +594,116 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
private scheduleNextFrame(): void {
|
||||
if (!this.frameLoopRunning) return;
|
||||
|
||||
const loopSessionId = this.sessionId;
|
||||
|
||||
this.nextFrameTime += FRAME_DURATION_MS;
|
||||
const now = performance.now();
|
||||
const delay = Math.max(0, this.nextFrameTime - now);
|
||||
const delay = Math.max(0, this.nextFrameTime - performance.now());
|
||||
|
||||
setTimeout(() => {
|
||||
// Discard callback from a stale play session
|
||||
if (loopSessionId !== this.sessionId) return;
|
||||
if (!this.frameLoopRunning) return;
|
||||
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
|
||||
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
|
||||
|
||||
if (this.state === "playing") {
|
||||
this.sendNextFrame();
|
||||
} else if (this.state === "paused") {
|
||||
this.nextFrameTime = performance.now();
|
||||
if (this.state === "playing") this.sendNextFrame();
|
||||
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||
|
||||
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
|
||||
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
|
||||
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
|
||||
const elapsed = this.getElapsed();
|
||||
const isNearEnd = this.currentSongDuration > 0
|
||||
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
|
||||
: true; // 未知时长时保守处理
|
||||
|
||||
// External mode: the sidecar PCM stream is continuous and never EOFs per
|
||||
// song; a transient underrun must NOT end the track (advance is driven by
|
||||
// the controller). Skip BOTH drain/stall branches while externalMode.
|
||||
//
|
||||
// R3-4: gate BOTH end-detection branches on state==="playing". While paused
|
||||
// the loop still ticks (so a resumed stream can refill), but it must NOT
|
||||
// accumulate stall attempts or emit trackEnd — otherwise pausing a stalled/
|
||||
// unknown-duration stream would auto-advance ~5s later. Because the if is
|
||||
// now false while paused, the else resets emptyFrameAttempts to 0, so a
|
||||
// resumed healthy stream starts fresh and never ends instantly.
|
||||
if (this.state === "playing" && !this.externalMode && this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
this.emptyFrameAttempts++;
|
||||
|
||||
// End the track when FFmpeg has gone silent: quickly if we're near the
|
||||
// end (normal EOF), or after a much longer stall window if we're not
|
||||
// (a dead/expired stream — #89 — so playback recovers instead of going
|
||||
// permanently silent).
|
||||
if (
|
||||
shouldEndOnStall(
|
||||
this.emptyFrameAttempts,
|
||||
isNearEnd,
|
||||
AudioPlayer.MAX_EMPTY_ATTEMPTS,
|
||||
AudioPlayer.MAX_STALL_ATTEMPTS,
|
||||
)
|
||||
) {
|
||||
this.logger.info({
|
||||
sessionId: this.sessionId,
|
||||
emptyAttempts: this.emptyFrameAttempts,
|
||||
bufferSize: this.pcmBuffer.length,
|
||||
elapsed: Math.round(elapsed),
|
||||
duration: this.currentSongDuration,
|
||||
remaining: Math.round(this.currentSongDuration - elapsed),
|
||||
nearEnd: isNearEnd,
|
||||
}, "FFmpeg stopped outputting data, ending track");
|
||||
this.frameLoopRunning = false;
|
||||
// The outer gate guarantees state==="playing" here, so no !=="idle"
|
||||
// guard is needed: end the track directly.
|
||||
this.state = "idle";
|
||||
// 清理FFmpeg进程
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
const pidToKill = procToKill.pid;
|
||||
this.ffmpeg = null;
|
||||
if (pidToKill) {
|
||||
this.forceCleanup(procToKill, pidToKill);
|
||||
}
|
||||
}
|
||||
this.consecutiveFailures = 0;
|
||||
this.emit("trackEnd");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 成功获取数据或FFmpeg已结束,重置计数器
|
||||
this.emptyFrameAttempts = 0;
|
||||
}
|
||||
|
||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
// R3-4: likewise gated on state==="playing" — a drained/EOF'd stream must
|
||||
// not emit trackEnd while paused; end-detection resumes on resume().
|
||||
if (this.state === "playing" && !this.externalMode && !this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
this.frameLoopRunning = false;
|
||||
if (this.state !== "idle") {
|
||||
this.state = "idle";
|
||||
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
|
||||
if (this.spawnFailed) {
|
||||
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
|
||||
} else {
|
||||
this.consecutiveFailures = 0; // Reset on successful track completion
|
||||
this.emit("trackEnd");
|
||||
}
|
||||
// Outer gate guarantees state==="playing"; end directly (no !=="idle" guard).
|
||||
this.state = "idle";
|
||||
if (!this.spawnFailed) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.emit("trackEnd");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleNextFrame();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendNextFrame(): void {
|
||||
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
||||
|
||||
if (this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
// External mode: the sidecar PCM stream is long-lived and must NOT end on
|
||||
// a transient underrun. Emit an encoded silence frame so the 20ms voice
|
||||
// timeline stays continuous instead of returning (which would desync TS).
|
||||
if (this.externalMode) this.emitSilenceFrame();
|
||||
return;
|
||||
}
|
||||
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
||||
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
||||
|
||||
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
|
||||
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
|
||||
this.ffmpeg.stdout.resume();
|
||||
this.ffmpegPaused = false;
|
||||
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER) {
|
||||
if (this.externalMode && this.externalStream) {
|
||||
this.externalStream.resume();
|
||||
this.ffmpegPaused = false;
|
||||
} else if (this.ffmpeg?.stdout) {
|
||||
this.ffmpeg.stdout.resume();
|
||||
this.ffmpegPaused = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -267,94 +711,60 @@ export class AudioPlayer extends EventEmitter {
|
||||
const opusFrame = this.encoder.encode(adjusted);
|
||||
this.emit("frame", opusFrame);
|
||||
this.framesPlayed++;
|
||||
|
||||
if (this.framesPlayed === 1) {
|
||||
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
|
||||
}
|
||||
// Log every ~10 seconds (500 frames * 20ms = 10s)
|
||||
if (this.framesPlayed % 500 === 0) {
|
||||
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
|
||||
this.healthyFrames++;
|
||||
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.healthyFrames = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, "Error encoding/sending audio frame");
|
||||
this.emit("error", err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private emitSilenceFrame(): void {
|
||||
try {
|
||||
const opusFrame = this.encoder.encode(Buffer.alloc(PCM_FRAME_BYTES));
|
||||
this.emit("frame", opusFrame);
|
||||
this.framesPlayed++;
|
||||
} catch (err) {
|
||||
this.emit("error", err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyVolume(pcm: Buffer): Buffer {
|
||||
if (this.volume === 100) return Buffer.from(pcm);
|
||||
const factor = this.volume / 100;
|
||||
const factor = volumeToFactor(this.volume);
|
||||
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
|
||||
if (factor >= 1) return Buffer.from(pcm);
|
||||
const out = Buffer.alloc(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i += 2) {
|
||||
let sample = pcm.readInt16LE(i);
|
||||
sample = Math.round(sample * factor);
|
||||
if (sample > 32767) sample = 32767;
|
||||
else if (sample < -32768) sample = -32768;
|
||||
out.writeInt16LE(sample, i);
|
||||
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Actual elapsed time in seconds (ground truth from frame count) */
|
||||
getElapsed(): number {
|
||||
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
|
||||
}
|
||||
|
||||
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
|
||||
// (framesPlayed includes silence frames emitted on underrun) and therefore
|
||||
// only APPROXIMATE — the authoritative position is the controller's live
|
||||
// status.track.position. This approximation is acceptable for Spotify.
|
||||
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
||||
seek(seconds: number): void {
|
||||
if (!this.currentUrl) return;
|
||||
this.logger.info({ seek: seconds }, "Seeking");
|
||||
this.play(this.currentUrl, seconds);
|
||||
}
|
||||
|
||||
getSeekOffset(): number {
|
||||
return this.seekOffset;
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.state === "playing") {
|
||||
this.state = "paused";
|
||||
this.logger.debug("Playback paused");
|
||||
// External (Spotify sidecar) mode: local seek is a no-op. Respawning ffmpeg
|
||||
// on the spotify: sentinel would collide with the continuous PCM source;
|
||||
// transport is delegated to the SpotifyController by the caller (Task 7).
|
||||
if (this.externalMode) return;
|
||||
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
|
||||
this.play(this.currentUrl, seconds, this.currentSongDuration);
|
||||
}
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.state === "paused") {
|
||||
this.state = "playing";
|
||||
this.nextFrameTime = performance.now();
|
||||
this.logger.debug("Playback resumed");
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.sessionId++;
|
||||
this.frameLoopRunning = false;
|
||||
if (this.ffmpeg) {
|
||||
this.ffmpeg.kill("SIGTERM");
|
||||
this.ffmpeg = null;
|
||||
}
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
this.state = "idle";
|
||||
this.currentUrl = "";
|
||||
this.seekOffset = 0;
|
||||
this.framesPlayed = 0;
|
||||
}
|
||||
|
||||
/** Reset the consecutive failure counter (e.g. after user action) */
|
||||
resetFailures(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
setVolume(vol: number): void {
|
||||
this.volume = Math.max(0, Math.min(100, vol));
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
||||
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
||||
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
|
||||
getVolume(): number { return this.volume; }
|
||||
getState(): PlayerState { return this.state; }
|
||||
// True only while attached to an external (Spotify sidecar) PCM stream. Used
|
||||
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
|
||||
// externalMode=false) so this is false after any player.stop().
|
||||
isExternalActive(): boolean { return this.externalMode; }
|
||||
}
|
||||
@@ -89,6 +89,49 @@ describe("PlayQueue", () => {
|
||||
expect(queue.list()[1].id).toBe("3");
|
||||
});
|
||||
|
||||
it("removing a song before current shifts current index", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.playAt(2); // playing C at index 2
|
||||
queue.remove(0); // remove A (before current)
|
||||
expect(queue.current()?.id).toBe("C"); // still on C
|
||||
expect(queue.getCurrentIndex()).toBe(1);
|
||||
});
|
||||
|
||||
it("removing the currently-playing song lets next() advance to the shifted song", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.add(makeSong("D"));
|
||||
queue.playAt(2); // playing C
|
||||
queue.remove(2); // remove C — D shifts into slot 2
|
||||
// Before the fix this returned null (D was silently skipped)
|
||||
expect(queue.next()?.id).toBe("D");
|
||||
});
|
||||
|
||||
it("removing the only song clears the queue", () => {
|
||||
queue.add(makeSong("only"));
|
||||
queue.playAt(0);
|
||||
queue.remove(0);
|
||||
expect(queue.size()).toBe(0);
|
||||
expect(queue.current()).toBeNull();
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("removing the last song while playing it advances to null in sequential mode", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.playAt(1); // playing B (last)
|
||||
queue.remove(1);
|
||||
expect(queue.size()).toBe(1);
|
||||
// currentIndex moved to 0, so next() should try to advance past the end
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all songs", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
@@ -107,6 +150,101 @@ describe("PlayQueue", () => {
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("random mode with single song returns null on next", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode plays each song exactly once then stops", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play();
|
||||
const played = new Set<string>();
|
||||
played.add(queue.current()!.id);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const song = queue.next();
|
||||
if (!song) break;
|
||||
played.add(song.id);
|
||||
}
|
||||
// All 3 songs should have been played
|
||||
expect(played).toEqual(new Set(["A", "B", "C"]));
|
||||
// next() after all played should return null
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: removing currently-playing song does not skip others", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.add(makeSong("D"));
|
||||
queue.play(); // plays A (index 0)
|
||||
const second = queue.next()!; // plays some song
|
||||
// Remove the currently-playing song
|
||||
const curIdx = queue.getCurrentIndex();
|
||||
queue.remove(curIdx);
|
||||
// Remaining songs (excluding A and the removed song) should all be reachable
|
||||
const played = new Set<string>();
|
||||
played.add("A"); // already played via play()
|
||||
played.add(second.id); // played and then removed
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
played.add(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// All 4 original songs should have been played or accounted for
|
||||
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
|
||||
});
|
||||
|
||||
it("random mode: prev does not cause duplicate plays", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B or C
|
||||
queue.prev(); // go back — this song is now marked as played
|
||||
// Exhaust remaining songs
|
||||
const ids: string[] = [];
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
ids.push(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// No song ID should appear more than once across the entire session
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("random mode: adding song mid-playback includes the new song", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B
|
||||
// Add a new song while all existing songs have been played
|
||||
queue.add(makeSong("C"));
|
||||
const song = queue.next();
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("C");
|
||||
// After C, should stop
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: setMode preserves current song as played", () => {
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A in sequential mode
|
||||
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
|
||||
// next() should only return B, never A again
|
||||
const song = queue.next();
|
||||
expect(song?.id).toBe("B");
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random-loop mode never returns null", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("1"));
|
||||
@@ -123,4 +261,348 @@ describe("PlayQueue", () => {
|
||||
queue.playAt(2);
|
||||
expect(queue.current()?.id).toBe("3");
|
||||
});
|
||||
|
||||
describe("history-aware prev", () => {
|
||||
it("walks back through played indices in random mode", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.add(makeSong("d"));
|
||||
queue.add(makeSong("e"));
|
||||
|
||||
// Force a deterministic random sequence: a → c → e
|
||||
queue.playAt(0);
|
||||
queue.playAt(2);
|
||||
queue.playAt(4);
|
||||
expect(queue.current()?.id).toBe("e");
|
||||
|
||||
// prev pops back through history: e → c → a
|
||||
expect(queue.prev()?.id).toBe("c");
|
||||
expect(queue.prev()?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("returns null when history is empty in random mode", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.playAt(0);
|
||||
// No further moves → history is empty (only 'a' is current, never pushed)
|
||||
expect(queue.prev()).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves sequential prev when history is empty", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.play();
|
||||
queue.next(); // currentIndex = 1
|
||||
// Sequential next() pushed 0 to history → prev pops back to 0
|
||||
expect(queue.prev()?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("clears history on play()", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.playAt(0);
|
||||
queue.playAt(1);
|
||||
queue.play(); // resets to index 0 and clears history
|
||||
expect(queue.prev()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears history on clear()", () => {
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.play();
|
||||
queue.next();
|
||||
queue.clear();
|
||||
queue.add(makeSong("c"));
|
||||
queue.play();
|
||||
// History was wiped — no prev path available beyond index 0
|
||||
expect(queue.prev()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears history on setMode()", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.play();
|
||||
queue.next();
|
||||
// Mode change resets context
|
||||
queue.setMode(PlayMode.Random);
|
||||
expect(queue.prev()).toBeNull();
|
||||
});
|
||||
|
||||
it("drops history entries pointing at a removed song", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.playAt(0);
|
||||
queue.playAt(1); // history: [0]
|
||||
queue.playAt(2); // history: [0, 1]
|
||||
// Remove song at index 1 → history entry 1 dropped
|
||||
queue.remove(1);
|
||||
// queue is now [a, c], history should be [0]
|
||||
// current was at 2 → after remove shifts to 1 → song "c"
|
||||
expect(queue.current()?.id).toBe("c");
|
||||
expect(queue.prev()?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("does not push to history on prev itself", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.playAt(0);
|
||||
queue.playAt(1);
|
||||
queue.playAt(2); // history: [0, 1]
|
||||
queue.prev(); // pops 1, history: [0]
|
||||
queue.prev(); // pops 0, history: []
|
||||
expect(queue.prev()).toBeNull(); // no fallback target in random mode
|
||||
});
|
||||
|
||||
it("caps history at HISTORY_LIMIT (50) entries, dropping oldest", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
// Build a queue large enough to overflow HISTORY_LIMIT
|
||||
for (let i = 0; i < 60; i++) queue.add(makeSong(`s${i}`));
|
||||
// Walk through 60 explicit picks → 59 pushes to history
|
||||
// (playAt pushes the previous currentIndex; first call has -1
|
||||
// which pushHistory rejects). After 60 playAts, history holds
|
||||
// the last 50 of those 59 entries.
|
||||
for (let i = 0; i < 60; i++) queue.playAt(i);
|
||||
|
||||
// Walk back through history. The first prev returns whatever the
|
||||
// 50th-most-recent push was (= index 9, since pushes 0..58 happened
|
||||
// and the oldest 9 fell off). We can verify by counting prevs that
|
||||
// succeed before history exhausts and prev returns null in random.
|
||||
let count = 0;
|
||||
while (queue.prev() !== null) {
|
||||
count++;
|
||||
if (count > 100) break; // safety
|
||||
}
|
||||
expect(count).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addNext", () => {
|
||||
it("appends when queue is empty (no current)", () => {
|
||||
queue.addNext(makeSong("a"));
|
||||
expect(queue.size()).toBe(1);
|
||||
expect(queue.list()[0].id).toBe("a");
|
||||
});
|
||||
|
||||
it("appends when nothing is currently playing (currentIndex < 0)", () => {
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
// No play() yet → currentIndex still -1
|
||||
queue.addNext(makeSong("c"));
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("inserts at currentIndex+1 mid-queue", () => {
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.add(makeSong("d"));
|
||||
queue.play(); // current = 0 (a)
|
||||
queue.next(); // current = 1 (b)
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
|
||||
expect(queue.current()?.id).toBe("b"); // current unchanged
|
||||
});
|
||||
|
||||
it("makes the inserted song play next when next() is called", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.play(); // current = 0 (a)
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.next()?.id).toBe("x");
|
||||
});
|
||||
|
||||
it("shifts playedIndices entries > currentIndex by +1", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.add(makeSong("d"));
|
||||
queue.playAt(2); // current = 2 (c), played = {2}
|
||||
queue.playAt(3); // current = 3 (d), played = {2, 3}
|
||||
queue.playAt(2); // current = 2 (c), played = {2, 3}
|
||||
// Now insert after c — d's index 3 should become 4
|
||||
queue.addNext(makeSong("x"));
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "x", "d"]);
|
||||
// After addNext: currentIndex still 2; played should be {2, 4}
|
||||
// (the previously-played 'd' is now at index 4)
|
||||
// Verify by removing 'x' (index 3) — d should remain played at index 3
|
||||
queue.remove(3);
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("shifts history entries > currentIndex by +1", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.add(makeSong("d"));
|
||||
queue.playAt(0); // current = 0
|
||||
queue.playAt(3); // current = 3 (d), history = [0]
|
||||
queue.playAt(1); // current = 1 (b), history = [0, 3]
|
||||
queue.addNext(makeSong("x"));
|
||||
// Insert at index 2 → entries > 1 shift +1 → history becomes [0, 4]
|
||||
// queue: [a, b, x, c, d]; d is now at index 4
|
||||
// prev → pop 4 → song at index 4 = d
|
||||
expect(queue.prev()?.id).toBe("d");
|
||||
// prev again → pop 0 → song at index 0 = a
|
||||
expect(queue.prev()?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("idle player + stale currentIndex: insertion target is currentIndex+1, not size-1", () => {
|
||||
// Reproduces the scenario where the player has gone idle but the
|
||||
// queue still has a non-negative currentIndex (e.g., after natural
|
||||
// track end without queue.clear()).
|
||||
queue.add(makeSong("a"));
|
||||
queue.add(makeSong("b"));
|
||||
queue.add(makeSong("c"));
|
||||
queue.add(makeSong("d"));
|
||||
queue.play(); // current = 0 (a)
|
||||
queue.next(); // current = 1 (b)
|
||||
// Simulate idle-with-stale-currentIndex: the player has gone idle
|
||||
// but queue still points at b.
|
||||
// Caller pre-captures insertedAt:
|
||||
const insertedAt = queue.getCurrentIndex() + 1; // = 2
|
||||
queue.addNext(makeSong("x"));
|
||||
// queue is now [a, b, x, c, d]
|
||||
// size-1 would be 4 (d) — WRONG.
|
||||
// insertedAt is 2 (x) — RIGHT.
|
||||
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
|
||||
expect(queue.size() - 1).toBe(4); // proves size-1 strategy would pick d
|
||||
const promoted = queue.playAt(insertedAt);
|
||||
expect(promoted?.id).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
|
||||
// songs repeated often while others were starved. It should behave like a
|
||||
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
|
||||
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
|
||||
describe("random-loop shuffle bag (issue #70)", () => {
|
||||
it("plays every song exactly once per cycle before repeating", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
const N = 12;
|
||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||
queue.play();
|
||||
|
||||
const cycle1 = [queue.current()!.id];
|
||||
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
|
||||
const cycle2: string[] = [];
|
||||
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
|
||||
|
||||
// Each cycle is a full permutation of all N songs — zero repeats within
|
||||
// a cycle, and both cycles cover the same complete set.
|
||||
expect(new Set(cycle1).size).toBe(N);
|
||||
expect(new Set(cycle2).size).toBe(N);
|
||||
expect(new Set(cycle1)).toEqual(new Set(cycle2));
|
||||
});
|
||||
|
||||
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
const N = 6;
|
||||
const CYCLES = 20;
|
||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||
queue.play();
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
counts.set(queue.current()!.id, 1);
|
||||
for (let i = 0; i < CYCLES * N - 1; i++) {
|
||||
const id = queue.next()!.id;
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Shuffle bag => each song plays exactly CYCLES times. True random
|
||||
// would skew heavily.
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(counts.get(`s${i}`)).toBe(CYCLES);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replay the same song across a cycle boundary", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
const N = 5;
|
||||
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||
queue.play();
|
||||
|
||||
// Walk to the last song of cycle 1, then cross into cycle 2.
|
||||
for (let i = 0; i < N - 1; i++) queue.next();
|
||||
const lastOfCycle1 = queue.current()!.id;
|
||||
const firstOfCycle2 = queue.next()!.id;
|
||||
expect(firstOfCycle2).not.toBe(lastOfCycle1);
|
||||
});
|
||||
|
||||
it("includes a song added mid-cycle within the current cycle", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // A
|
||||
queue.next(); // B — both originals now played this cycle
|
||||
queue.add(makeSong("C")); // added mid-cycle, still unplayed
|
||||
// C is the only unplayed song, so it must come next (not a reshuffle).
|
||||
expect(queue.next()?.id).toBe("C");
|
||||
});
|
||||
|
||||
it("keeps looping forever with multiple songs (never returns null)", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
expect(queue.next()).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+195
-23
@@ -10,16 +10,41 @@ export interface QueuedSong {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||
url?: string; // resolved lazily at play time
|
||||
coverUrl: string;
|
||||
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 {
|
||||
private songs: QueuedSong[] = [];
|
||||
private currentIndex = -1;
|
||||
private mode: PlayMode = PlayMode.Sequential;
|
||||
private playedIndices = new Set<number>();
|
||||
private history: number[] = [];
|
||||
private forwardStack: number[] = [];
|
||||
private static readonly HISTORY_LIMIT = 50;
|
||||
|
||||
private pushHistory(idx: number): void {
|
||||
if (idx < 0 || idx >= this.songs.length) return;
|
||||
this.history.push(idx);
|
||||
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
|
||||
this.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
add(song: QueuedSong): void {
|
||||
this.songs.push(song);
|
||||
@@ -29,6 +54,34 @@ export class PlayQueue {
|
||||
this.songs.push(...songs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a song to play immediately after the current one. Falls
|
||||
* through to plain push when nothing is playing yet (currentIndex < 0
|
||||
* or queue empty), so the existing "add → idle bot starts playing"
|
||||
* flow continues to work.
|
||||
*
|
||||
* Shifts playedIndices and history entries > currentIndex by +1 so
|
||||
* their references stay valid after the splice.
|
||||
*/
|
||||
addNext(song: QueuedSong): void {
|
||||
if (this.currentIndex < 0 || this.songs.length === 0) {
|
||||
this.songs.push(song);
|
||||
return;
|
||||
}
|
||||
const insertAt = this.currentIndex + 1;
|
||||
this.songs.splice(insertAt, 0, song);
|
||||
|
||||
const shifted = new Set<number>();
|
||||
for (const i of this.playedIndices) {
|
||||
shifted.add(i > this.currentIndex ? i + 1 : i);
|
||||
}
|
||||
this.playedIndices = shifted;
|
||||
|
||||
this.history = this.history.map((i) =>
|
||||
i > this.currentIndex ? i + 1 : i,
|
||||
);
|
||||
}
|
||||
|
||||
remove(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
const [removed] = this.songs.splice(index, 1);
|
||||
@@ -36,28 +89,54 @@ export class PlayQueue {
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (index === this.currentIndex) {
|
||||
if (this.currentIndex >= this.songs.length) {
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
}
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
// Rebuild playedIndices to account for shifted indices
|
||||
const newPlayed = new Set<number>();
|
||||
for (const idx of this.playedIndices) {
|
||||
if (idx === index) continue;
|
||||
newPlayed.add(idx > index ? idx - 1 : idx);
|
||||
}
|
||||
this.playedIndices = newPlayed;
|
||||
|
||||
// Same shift logic for history — drop entries pointing at the
|
||||
// removed song; shift entries > index down by 1.
|
||||
this.history = this.history
|
||||
.filter((idx) => idx !== index)
|
||||
.map((idx) => (idx > index ? idx - 1 : idx));
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.songs = [];
|
||||
this.currentIndex = -1;
|
||||
this.playedIndices.clear();
|
||||
this.history = [];
|
||||
this.forwardStack = [];
|
||||
}
|
||||
|
||||
play(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
this.playedIndices.clear();
|
||||
this.history = [];
|
||||
this.forwardStack = [];
|
||||
this.currentIndex = 0;
|
||||
this.playedIndices.add(0);
|
||||
return this.songs[0];
|
||||
}
|
||||
|
||||
playAt(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
this.pushHistory(this.currentIndex);
|
||||
// Reset the Random-mode "unplayed" pool — explicit picks restart
|
||||
// shuffle from this point. History tracking is independent and
|
||||
// unaffected by this clear.
|
||||
this.playedIndices.clear();
|
||||
this.forwardStack = [];
|
||||
this.currentIndex = index;
|
||||
this.playedIndices.add(index);
|
||||
return this.songs[index];
|
||||
}
|
||||
|
||||
@@ -68,47 +147,101 @@ export class PlayQueue {
|
||||
case PlayMode.Sequential: {
|
||||
const nextIndex = this.currentIndex + 1;
|
||||
if (nextIndex >= this.songs.length) return null;
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.Loop: {
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
case PlayMode.Random: {
|
||||
if (this.songs.length === 1) return this.songs[0];
|
||||
let nextIndex: number;
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.Random:
|
||||
case PlayMode.RandomLoop: {
|
||||
if (this.songs.length === 1) {
|
||||
this.currentIndex = 0;
|
||||
return this.songs[0];
|
||||
// 优先回到前进栈记录的位置(prev 退回的歌)
|
||||
if (this.forwardStack.length > 0) {
|
||||
const target = this.forwardStack.pop()!;
|
||||
if (target !== this.currentIndex) {
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = target;
|
||||
this.playedIndices.add(target);
|
||||
return this.songs[target];
|
||||
}
|
||||
}
|
||||
let idx: number;
|
||||
do {
|
||||
idx = Math.floor(Math.random() * this.songs.length);
|
||||
} while (idx === this.currentIndex);
|
||||
this.currentIndex = idx;
|
||||
return this.songs[idx];
|
||||
|
||||
// Shuffle bag: pick uniformly from the songs not yet played this
|
||||
// cycle, so every song plays once before any repeats (NetEase/QQ
|
||||
// style). Songs added mid-cycle aren't in playedIndices, so they're
|
||||
// naturally eligible within the current cycle.
|
||||
const unplayed: number[] = [];
|
||||
for (let i = 0; i < this.songs.length; i++) {
|
||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||
}
|
||||
|
||||
if (unplayed.length === 0) {
|
||||
// Cycle complete.
|
||||
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
|
||||
// 随机循环:reshuffle and keep going forever.
|
||||
if (this.songs.length === 1) {
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = 0;
|
||||
this.playedIndices = new Set([0]);
|
||||
return this.songs[0];
|
||||
}
|
||||
// Start a fresh cycle: every song is eligible again, but exclude
|
||||
// the song that just played from THIS pick only, so it doesn't
|
||||
// repeat back-to-back across the boundary. It stays eligible for
|
||||
// the rest of the new cycle, so every song still plays exactly once.
|
||||
this.playedIndices = new Set();
|
||||
for (let i = 0; i < this.songs.length; i++) {
|
||||
if (i !== this.currentIndex) unplayed.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex =
|
||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||
this.pushHistory(this.currentIndex);
|
||||
this.currentIndex = nextIndex;
|
||||
this.playedIndices.add(nextIndex);
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prev(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
|
||||
// 记录当前位置到前进栈,供 next 优先返回
|
||||
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
|
||||
this.forwardStack.push(this.currentIndex);
|
||||
}
|
||||
|
||||
// Preferred: pop from the back-stack so prev means "the song I
|
||||
// actually played before this one," not "the previous array slot."
|
||||
while (this.history.length > 0) {
|
||||
const idx = this.history.pop()!;
|
||||
if (idx >= 0 && idx < this.songs.length) {
|
||||
this.currentIndex = idx;
|
||||
this.playedIndices = new Set([...this.history, this.currentIndex]);
|
||||
return this.songs[idx];
|
||||
}
|
||||
// Stale entry (song removed) — keep popping.
|
||||
}
|
||||
|
||||
// Fallback: no history to walk back through. In Sequential we
|
||||
// can still meaningfully step the index backward; in random
|
||||
// modes there's nothing useful to return.
|
||||
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
|
||||
return null;
|
||||
}
|
||||
const prevIndex = this.currentIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
// In Sequential mode, don't wrap around
|
||||
if (this.mode === PlayMode.Sequential) return null;
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
@@ -136,9 +269,48 @@ export class PlayQueue {
|
||||
|
||||
setMode(mode: PlayMode): void {
|
||||
this.mode = mode;
|
||||
this.playedIndices.clear();
|
||||
this.history = [];
|
||||
this.forwardStack = [];
|
||||
if (this.currentIndex >= 0) {
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
|
||||
/** Number of songs not yet played in Random mode. */
|
||||
unplayedCount(): number {
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decideOccupancyAction,
|
||||
occupancyFromClientList,
|
||||
shouldResumeOnReturn,
|
||||
} from "./auto-pause.js";
|
||||
|
||||
describe("decideOccupancyAction", () => {
|
||||
it("pauses when empty while playing and enabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||
});
|
||||
it("does not pause when the feature is disabled", () => {
|
||||
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when idle (nothing playing)", () => {
|
||||
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("does not pause when already paused", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||
});
|
||||
it("resumes when re-populated and we auto-paused", () => {
|
||||
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||
});
|
||||
it("does NOT resume a user-paused track on re-population", () => {
|
||||
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("does nothing when re-populated and already playing", () => {
|
||||
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||
});
|
||||
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||
});
|
||||
});
|
||||
|
||||
describe("occupancyFromClientList", () => {
|
||||
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
|
||||
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
|
||||
// return [], which must be treated as "unknown", NOT as an empty channel.
|
||||
expect(occupancyFromClientList(0)).toBeNull();
|
||||
});
|
||||
it("returns 0 other users when only the bot is in the channel", () => {
|
||||
expect(occupancyFromClientList(1)).toBe(0);
|
||||
});
|
||||
it("excludes the bot itself from the count", () => {
|
||||
expect(occupancyFromClientList(2)).toBe(1);
|
||||
expect(occupancyFromClientList(5)).toBe(4);
|
||||
});
|
||||
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
|
||||
expect(occupancyFromClientList(-3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldResumeOnReturn (event-driven auto-resume)", () => {
|
||||
it("resumes when we auto-paused and are still paused", () => {
|
||||
// The reported gap: someone returns after an auto-pause. clientlist can't
|
||||
// confirm it (it times out while they're present), so we resume from the
|
||||
// clientEnter event alone.
|
||||
expect(shouldResumeOnReturn(true, "paused")).toBe(true);
|
||||
});
|
||||
it("does NOT resume a track the user paused by hand", () => {
|
||||
expect(shouldResumeOnReturn(false, "paused")).toBe(false);
|
||||
});
|
||||
it("does nothing if already playing (e.g. the bot's own enter at connect)", () => {
|
||||
// autoPaused is cleared to false on connect, so the bot's own clientEnter
|
||||
// is a no-op; this also covers the playing/auto-paused-flag-stale case.
|
||||
expect(shouldResumeOnReturn(false, "playing")).toBe(false);
|
||||
expect(shouldResumeOnReturn(true, "playing")).toBe(false);
|
||||
});
|
||||
it("does nothing when idle (nothing to resume)", () => {
|
||||
expect(shouldResumeOnReturn(true, "idle")).toBe(false);
|
||||
expect(shouldResumeOnReturn(false, "idle")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||
export type OccupancyAction = "pause" | "resume" | "none";
|
||||
|
||||
/**
|
||||
* Convert a channel client-list length into the number of *other* users, or
|
||||
* `null` when occupancy can't be determined.
|
||||
*
|
||||
* A connected bot is always a member of its own channel, so a valid query
|
||||
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
|
||||
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
|
||||
* full-client `clientlist` command times out when other clients are present,
|
||||
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
|
||||
* "empty" is what caused playback to auto-pause within seconds whenever a
|
||||
* listener was actually in the channel. When the result is indeterminate we
|
||||
* return `null` so callers skip the auto-pause/idle decision entirely rather
|
||||
* than mis-reading an unknown state as empty.
|
||||
*/
|
||||
export function occupancyFromClientList(clientCount: number): number | null {
|
||||
if (clientCount <= 0) return null; // query failed → occupancy unknown
|
||||
return clientCount - 1; // exclude the bot itself
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what auto-pause should do given channel occupancy.
|
||||
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||
*/
|
||||
export function decideOccupancyAction(
|
||||
playerState: PlayerStateName,
|
||||
autoPaused: boolean,
|
||||
enabled: boolean,
|
||||
userCount: number,
|
||||
): OccupancyAction {
|
||||
const empty = userCount <= 0;
|
||||
if (empty) {
|
||||
if (enabled && playerState === "playing") return "pause";
|
||||
return "none";
|
||||
}
|
||||
if (autoPaused && playerState === "paused") return "resume";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a client-presence push event (a `clientEnter`) should trigger an
|
||||
* auto-resume, WITHOUT consulting a clientlist query.
|
||||
*
|
||||
* Why event-driven: the full-client `clientlist`/`channellist` commands time
|
||||
* out whenever ≥2 clients are connected to the server (a library limitation) —
|
||||
* which is exactly the moment a listener returns. So occupancy cannot be
|
||||
* re-queried to confirm the return; we must act on the push event itself.
|
||||
* This is sound because the bot only ever auto-pauses while it is alone on the
|
||||
* server (the sole state in which the occupancy query succeeds and pause
|
||||
* fires). Therefore, while `autoPaused` is true, the only way occupancy can
|
||||
* return is a fresh connection — delivered reliably as `clientEnter`.
|
||||
*
|
||||
* Gating on `autoPaused` (not merely "paused") guarantees we never revive a
|
||||
* track the user paused by hand, and makes the bot's own `clientEnter` at
|
||||
* connect a no-op (autoPaused is cleared to false on connect). This predicate
|
||||
* NEVER pauses — pause stays on the authoritative clientlist path.
|
||||
*/
|
||||
export function shouldResumeOnReturn(
|
||||
autoPaused: boolean,
|
||||
playerState: PlayerStateName,
|
||||
): boolean {
|
||||
return autoPaused && playerState === "paused";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCommand } from "./commands.js";
|
||||
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
|
||||
|
||||
describe("Command Parser", () => {
|
||||
it("parses simple command", () => {
|
||||
@@ -60,3 +60,36 @@ describe("Command Parser", () => {
|
||||
expect(result!.args).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdminCommand classification", () => {
|
||||
it("treats stop/clear/remove/move/vol/mode as admin", () => {
|
||||
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
|
||||
expect(isAdminCommand(c)).toBe(true);
|
||||
}
|
||||
});
|
||||
it("treats follow and play as NOT admin", () => {
|
||||
expect(isAdminCommand("follow")).toBe(false);
|
||||
expect(isAdminCommand("play")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canRunCommand", () => {
|
||||
it("allows any public command regardless of groups", () => {
|
||||
expect(canRunCommand("play", [], [6])).toBe(true);
|
||||
expect(canRunCommand("follow", [], [6])).toBe(true);
|
||||
});
|
||||
it("allows admin command when enforcement is off (empty adminGroups)", () => {
|
||||
expect(canRunCommand("stop", [], [])).toBe(true);
|
||||
});
|
||||
it("allows admin command when an invoker group matches (string vs number)", () => {
|
||||
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
|
||||
expect(canRunCommand("stop", [6], [6])).toBe(true);
|
||||
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
|
||||
});
|
||||
it("denies admin command when no invoker group matches", () => {
|
||||
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
|
||||
});
|
||||
it("denies admin command when invoker has no groups and enforcement is on", () => {
|
||||
expect(canRunCommand("clear", [], [6])).toBe(false);
|
||||
});
|
||||
});
|
||||
+27
-6
@@ -5,13 +5,13 @@ export interface ParsedCommand {
|
||||
flags: Set<string>;
|
||||
}
|
||||
|
||||
export const PUBLIC_COMMANDS = new Set([
|
||||
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
||||
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
|
||||
* for which commands the permission gate restricts; reclassifying a command is
|
||||
* a one-line edit here. Everything not in this set is public.
|
||||
*/
|
||||
export const ADMIN_COMMANDS = new Set([
|
||||
"stop", "clear", "move", "vol", "mode", "follow", "remove",
|
||||
"stop", "clear", "remove", "move", "vol", "mode",
|
||||
]);
|
||||
|
||||
export function parseCommand(
|
||||
@@ -58,3 +58,24 @@ export function parseCommand(
|
||||
export function isAdminCommand(commandName: string): boolean {
|
||||
return ADMIN_COMMANDS.has(commandName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a chat command may run, given the invoker's TS server groups
|
||||
* and the configured admin groups. Pure + synchronous so it is trivially unit
|
||||
* tested and reused by the async gate in BotInstance.
|
||||
*
|
||||
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
|
||||
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
|
||||
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
|
||||
* strings before comparison so "6" matches 6.
|
||||
*/
|
||||
export function canRunCommand(
|
||||
commandName: string,
|
||||
invokerGroups: readonly (string | number)[],
|
||||
adminGroups: readonly number[],
|
||||
): boolean {
|
||||
if (!isAdminCommand(commandName)) return true;
|
||||
if (adminGroups.length === 0) return true;
|
||||
const admin = new Set(adminGroups.map((g) => String(g)));
|
||||
return invokerGroups.some((g) => admin.has(String(g)));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Regular → Executable
+1438
-88
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { BotManager } from "./manager.js";
|
||||
import { createDatabase, type BotDatabase } from "../data/database.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
|
||||
// removeBot only calls logger.info; provide the full shape it could touch.
|
||||
const stubLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
child() {
|
||||
return stubLogger;
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("BotManager.removeBot — guest scope pruning", () => {
|
||||
const dirs: string[] = [];
|
||||
let db: BotDatabase;
|
||||
|
||||
function makeTmpConfigPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "config.json");
|
||||
}
|
||||
|
||||
function makeManager(config: BotConfig, configPath: string): BotManager {
|
||||
db = createDatabase(":memory:");
|
||||
const permissions = createPermissionStore(db.db);
|
||||
saveConfig(configPath, config);
|
||||
return new BotManager(
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
db,
|
||||
config,
|
||||
stubLogger,
|
||||
{} as unknown as AvatarStore,
|
||||
permissions,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = ["botA", "botB"];
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toEqual(["botB"]);
|
||||
// Persisted file must also reflect the prune.
|
||||
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
|
||||
});
|
||||
|
||||
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = "all";
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toBe("all");
|
||||
expect(loadConfig(configPath).guestMode.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
|
||||
// The single process-wide SpotifyOAuth built in index.ts must reach every bot's
|
||||
// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance
|
||||
// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController,
|
||||
// so we assert the shared instance surfaces via the controller's getOAuth().
|
||||
describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => {
|
||||
const dirs: string[] = [];
|
||||
let db: BotDatabase | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
db = undefined;
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("forwards its shared SpotifyOAuth into a created bot's controller", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-"));
|
||||
dirs.push(dir);
|
||||
const configPath = join(dir, "config.json");
|
||||
const config = getDefaultConfig();
|
||||
saveConfig(configPath, config);
|
||||
db = createDatabase(":memory:");
|
||||
const permissions = createPermissionStore(db.db);
|
||||
const provider = {} as unknown as MusicProvider;
|
||||
const sentinel = {} as unknown as SpotifyOAuth;
|
||||
|
||||
const manager = new BotManager(
|
||||
provider,
|
||||
provider,
|
||||
provider,
|
||||
db,
|
||||
config,
|
||||
stubLogger,
|
||||
{} as unknown as AvatarStore,
|
||||
permissions,
|
||||
configPath,
|
||||
undefined, // localProvider
|
||||
undefined, // kugouProvider
|
||||
undefined, // spotifyProvider
|
||||
join(dir, "spotify"), // spotifyDataDir
|
||||
sentinel, // spotifyOAuth (the single shared instance)
|
||||
);
|
||||
|
||||
const bot = await manager.createBot({
|
||||
name: "b1",
|
||||
serverAddress: "localhost",
|
||||
serverPort: 9987,
|
||||
nickname: "b1",
|
||||
});
|
||||
|
||||
// Full chain observed: the manager's single shared instance is the exact
|
||||
// one the per-bot controller now owns (getOAuth() returns it unchanged).
|
||||
expect(bot.getSpotifyController().getOAuth()).toBe(sentinel);
|
||||
|
||||
bot.disconnect();
|
||||
});
|
||||
});
|
||||
+202
-9
@@ -1,13 +1,57 @@
|
||||
import crypto from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BotInstance,
|
||||
type BotInstanceOptions,
|
||||
} from "./instance.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import { YouTubeProvider } from "../music/youtube.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { PermissionStore } from "../data/permissions.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
|
||||
/**
|
||||
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
|
||||
* server silently drops the connection after initivexpand2), we tear the
|
||||
* instance down instead of waiting for the library's 60s idle timeout, so
|
||||
* the HTTP /start call returns promptly and the UI doesn't lock up.
|
||||
*/
|
||||
async function connectWithTimeout(
|
||||
bot: BotInstance,
|
||||
ms: number,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`connect timeout after ${ms}ms`)),
|
||||
ms
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([bot.connect(), timeout]);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, botId: bot.id },
|
||||
"Connect failed or timed out — tearing down instance"
|
||||
);
|
||||
try {
|
||||
bot.disconnect();
|
||||
} catch {
|
||||
// ignore teardown errors
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateBotParams {
|
||||
name: string;
|
||||
serverAddress: string;
|
||||
@@ -15,18 +59,35 @@ export interface CreateBotParams {
|
||||
queryPort?: number;
|
||||
nickname: string;
|
||||
defaultChannel?: string;
|
||||
channelId?: string;
|
||||
channelPassword?: string;
|
||||
autoStart?: boolean;
|
||||
/** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */
|
||||
serverProtocol?: ServerProtocol;
|
||||
/** API key for TS6 HTTP Query (port 10080/10443). */
|
||||
ts6ApiKey?: string;
|
||||
/** Password required to join the TS server. */
|
||||
serverPassword?: string;
|
||||
}
|
||||
|
||||
export class BotManager {
|
||||
export class BotManager extends EventEmitter {
|
||||
private bots = new Map<string, BotInstance>();
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private bilibiliProvider: MusicProvider;
|
||||
private youtubeProvider: MusicProvider;
|
||||
private localProvider: MusicProvider;
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private jellyfinProvider: MusicProvider;
|
||||
private spotifyDataDir: string;
|
||||
private readonly spotifyOAuth?: SpotifyOAuth;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
private avatarStore: AvatarStore;
|
||||
private permissions: PermissionStore;
|
||||
private configPath: string;
|
||||
|
||||
constructor(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -34,14 +95,40 @@ export class BotManager {
|
||||
bilibiliProvider: MusicProvider,
|
||||
database: BotDatabase,
|
||||
config: BotConfig,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore,
|
||||
configPath: string,
|
||||
localProvider?: MusicProvider,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider,
|
||||
spotifyDataDir?: string,
|
||||
spotifyOAuth?: SpotifyOAuth,
|
||||
jellyfinProvider?: MusicProvider
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
this.qqProvider = qqProvider;
|
||||
this.bilibiliProvider = bilibiliProvider;
|
||||
this.youtubeProvider = new YouTubeProvider();
|
||||
this.localProvider = localProvider ?? neteaseProvider;
|
||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
||||
this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
|
||||
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||
this.spotifyOAuth = spotifyOAuth;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||
const referenceable = this.localProvider as Partial<{
|
||||
setInUseResolver: (resolver: () => Set<string>) => void;
|
||||
}>;
|
||||
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
|
||||
this.database = database;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
this.avatarStore = avatarStore;
|
||||
this.permissions = permissions;
|
||||
this.configPath = configPath;
|
||||
}
|
||||
|
||||
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
||||
@@ -56,17 +143,30 @@ export class BotManager {
|
||||
queryPort: params.queryPort ?? 10011,
|
||||
nickname: params.nickname,
|
||||
defaultChannel: params.defaultChannel,
|
||||
channelId: params.channelId,
|
||||
channelPassword: params.channelPassword,
|
||||
serverPassword: params.serverPassword,
|
||||
serverProtocol: params.serverProtocol,
|
||||
ts6ApiKey: params.ts6ApiKey,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
|
||||
this.bots.set(id, bot);
|
||||
this.emit("botInstance", bot);
|
||||
|
||||
this.database.saveBotInstance({
|
||||
id,
|
||||
@@ -75,8 +175,12 @@ export class BotManager {
|
||||
serverPort: params.serverPort,
|
||||
nickname: params.nickname,
|
||||
defaultChannel: params.defaultChannel ?? "",
|
||||
channelId: params.channelId ?? "",
|
||||
channelPassword: params.channelPassword ?? "",
|
||||
autoStart: params.autoStart ?? false,
|
||||
serverProtocol: params.serverProtocol ?? "",
|
||||
ts6ApiKey: params.ts6ApiKey ?? "",
|
||||
serverPassword: params.serverPassword ?? "",
|
||||
});
|
||||
|
||||
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
|
||||
@@ -90,6 +194,13 @@ export class BotManager {
|
||||
this.bots.delete(id);
|
||||
}
|
||||
this.database.deleteBotInstance(id);
|
||||
this.permissions.pruneBot(id);
|
||||
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
|
||||
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
|
||||
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
|
||||
saveConfig(this.configPath, this.config);
|
||||
}
|
||||
this.emit("botInstanceRemoved", id);
|
||||
this.logger.info({ botId: id }, "Bot instance removed");
|
||||
}
|
||||
|
||||
@@ -105,7 +216,11 @@ export class BotManager {
|
||||
serverPort: params.serverPort ?? existing.serverPort,
|
||||
nickname: params.nickname ?? existing.nickname,
|
||||
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
|
||||
channelId: params.channelId ?? existing.channelId,
|
||||
channelPassword: params.channelPassword ?? existing.channelPassword,
|
||||
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
|
||||
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
|
||||
serverPassword: params.serverPassword ?? existing.serverPassword,
|
||||
});
|
||||
// Update in-memory name immediately (other fields need reconnect)
|
||||
const bot = this.bots.get(id);
|
||||
@@ -127,16 +242,80 @@ export class BotManager {
|
||||
return Array.from(this.bots.values());
|
||||
}
|
||||
|
||||
async startBot(id: string): Promise<void> {
|
||||
const bot = this.bots.get(id);
|
||||
if (!bot) throw new Error(`Bot ${id} not found`);
|
||||
await bot.connect();
|
||||
/** Local upload ids still referenced by any bot's queue. The local provider
|
||||
* uses this to avoid deleting a file another queue/bot is still using. */
|
||||
getReferencedLocalSongIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const bot of this.bots.values()) {
|
||||
for (const song of bot.getQueueManager().list()) {
|
||||
if (song.platform === "local") ids.add(song.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Mark as autoStart so it reconnects on Docker restart, and persist identity
|
||||
async startBot(id: string): Promise<void> {
|
||||
const oldBot = this.bots.get(id);
|
||||
if (!oldBot) throw new Error(`Bot ${id} not found`);
|
||||
|
||||
// Always tear down the outgoing instance before creating a replacement.
|
||||
// Covers three cases:
|
||||
// 1. oldBot is fully connected (manual restart)
|
||||
// 2. oldBot is mid-handshake from a prior rapid start (isConnected()
|
||||
// still returns false but the library client is live and will leak
|
||||
// a TS session if we abandon it)
|
||||
// 3. oldBot was just created by createBot but never connected — the
|
||||
// disconnect call is a cheap no-op here.
|
||||
// Calling disconnect() is idempotent (disconnectEmitted guards event
|
||||
// emission), so this is safe in all states.
|
||||
oldBot.disconnect();
|
||||
|
||||
// Reload config from database so updated settings (channel, nickname, etc.) take effect
|
||||
const saved = this.database.getBotInstances().find((i) => i.id === id);
|
||||
if (saved) {
|
||||
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
|
||||
const bot = new BotInstance({
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
tsOptions: {
|
||||
host: saved.serverAddress,
|
||||
port: saved.serverPort,
|
||||
queryPort: proto === "ts6" ? 10080 : 10011,
|
||||
nickname: saved.nickname,
|
||||
// Reuse the stored identity so server groups assigned to this bot
|
||||
// survive restarts — without this the TS server sees a new UID
|
||||
// each connect and strips all previously granted groups.
|
||||
identity: saved.identity || undefined,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelId: saved.channelId || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
serverPassword: saved.serverPassword || undefined,
|
||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
||||
ts6ApiKey: saved.ts6ApiKey || undefined,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
this.bots.set(id, bot);
|
||||
this.emit("botInstance", bot);
|
||||
await connectWithTimeout(bot, 15_000, this.logger);
|
||||
// Mark as autoStart so it reconnects on Docker restart, and persist identity
|
||||
this.database.saveBotInstance({ ...saved, autoStart: true });
|
||||
this.persistBotIdentity(saved, bot);
|
||||
} else {
|
||||
await connectWithTimeout(oldBot, 15_000, this.logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,27 +334,41 @@ export class BotManager {
|
||||
async loadSavedBots(): Promise<void> {
|
||||
const savedInstances = this.database.getBotInstances();
|
||||
for (const saved of savedInstances) {
|
||||
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
|
||||
const bot = new BotInstance({
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
tsOptions: {
|
||||
host: saved.serverAddress,
|
||||
port: saved.serverPort,
|
||||
queryPort: 10011,
|
||||
queryPort: proto === "ts6" ? 10080 : 10011,
|
||||
nickname: saved.nickname,
|
||||
identity: saved.identity || undefined,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelId: saved.channelId || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
serverPassword: saved.serverPassword || undefined,
|
||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
||||
ts6ApiKey: saved.ts6ApiKey || undefined,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
bilibiliProvider: this.bilibiliProvider,
|
||||
youtubeProvider: this.youtubeProvider,
|
||||
localProvider: this.localProvider,
|
||||
kugouProvider: this.kugouProvider,
|
||||
spotifyProvider: this.spotifyProvider,
|
||||
jellyfinProvider: this.jellyfinProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
|
||||
this.bots.set(saved.id, bot);
|
||||
this.emit("botInstance", bot);
|
||||
|
||||
// Only auto-connect bots that have autoStart enabled
|
||||
if (saved.autoStart) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import type { TS3Client } from "../ts-protocol/client.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
|
||||
function makeMockTs(): TS3Client & {
|
||||
uploadCalls: Buffer[];
|
||||
clearCalls: number;
|
||||
} {
|
||||
const calls: Buffer[] = [];
|
||||
let clears = 0;
|
||||
const ts: any = {
|
||||
uploadCalls: calls,
|
||||
get clearCalls() { return clears; },
|
||||
getHost: () => "127.0.0.1",
|
||||
getHttpQuery: () => null,
|
||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(c as Buffer);
|
||||
calls.push(Buffer.concat(chunks));
|
||||
}),
|
||||
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
|
||||
if (/client_flag_avatar=$/.test(cmd)) clears++;
|
||||
}),
|
||||
};
|
||||
return ts;
|
||||
}
|
||||
|
||||
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
|
||||
const cfgOff = { ...cfgOn, avatarEnabled: false };
|
||||
|
||||
const fakeSong: QueuedSong = {
|
||||
id: "1",
|
||||
name: "X",
|
||||
artist: "Y",
|
||||
album: "Z",
|
||||
platform: "netease",
|
||||
url: "u",
|
||||
coverUrl: "c",
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
describe("BotProfileManager custom avatar precedence", () => {
|
||||
let ts: ReturnType<typeof makeMockTs>;
|
||||
beforeEach(() => { ts = makeMockTs(); });
|
||||
|
||||
it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([1, 2, 3]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||
});
|
||||
|
||||
it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([7]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
// Simulate the bot playing a song. We can't actually run updateAvatar's
|
||||
// full HTTP fetch path, but onSongChange records currentSong before
|
||||
// updateAvatar runs, which is enough for this assertion.
|
||||
void pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
const uploadsBefore = ts.uploadCalls.length;
|
||||
pm.setCustomAvatar(Buffer.from([42]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload
|
||||
});
|
||||
|
||||
it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
void pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
const uploadsBefore = ts.uploadCalls.length;
|
||||
pm.setCustomAvatar(Buffer.from([42]));
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(uploadsBefore + 1);
|
||||
});
|
||||
|
||||
it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([1]));
|
||||
await flush();
|
||||
const clearsBefore = ts.clearCalls;
|
||||
pm.setCustomAvatar(null);
|
||||
await flush();
|
||||
expect(ts.clearCalls).toBe(clearsBefore + 1);
|
||||
});
|
||||
|
||||
it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([1, 2, 3, 4]));
|
||||
await flush();
|
||||
const clearsBefore = ts.clearCalls;
|
||||
await pm.onSongChange(null);
|
||||
expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true);
|
||||
expect(ts.clearCalls).toBe(clearsBefore); // no extra clear
|
||||
});
|
||||
|
||||
it("on stop with no custom avatar, falls back to clear", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
await pm.onSongChange(null);
|
||||
expect(ts.clearCalls).toBe(1);
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([5, 5]));
|
||||
await flush();
|
||||
ts.uploadCalls.length = 0; // reset
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true);
|
||||
});
|
||||
|
||||
it("on connect with custom avatar set + sync OFF, applies custom", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
pm.setCustomAvatar(Buffer.from([9, 9]));
|
||||
await flush();
|
||||
ts.uploadCalls.length = 0;
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(1);
|
||||
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
|
||||
});
|
||||
|
||||
it("on connect with no custom avatar, does not touch avatar", async () => {
|
||||
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||
pm.onConnect();
|
||||
await flush();
|
||||
expect(ts.uploadCalls.length).toBe(0);
|
||||
expect(ts.clearCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import axios from "axios";
|
||||
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
||||
import { HttpQueryError } from "../ts-protocol/http-query.js";
|
||||
import type { ProfileConfig } from "../data/database.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
const TS3_NICKNAME_MAX = 30;
|
||||
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
|
||||
const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
*
|
||||
* Every update is permission-safe: if a feature fails due to insufficient
|
||||
* server permissions, it silently disables itself until the next reconnect.
|
||||
*/
|
||||
export class BotProfileManager {
|
||||
private tsClient: TS3Client;
|
||||
private logger: Logger;
|
||||
private config: ProfileConfig;
|
||||
private defaultNickname: string;
|
||||
private customAvatar: Buffer | null = null;
|
||||
/**
|
||||
* Tracks the last song handed to onSongChange. null means stopped/idle.
|
||||
* Used by setCustomAvatar to decide whether the new buffer should be
|
||||
* pushed immediately (idle) or wait for the next stop event (playing).
|
||||
*/
|
||||
private currentSong: QueuedSong | null = null;
|
||||
|
||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||
private permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Monotonically increasing generation counter. Incremented on every
|
||||
* onSongChange / onConnect call. Long-running operations (avatar
|
||||
* download/upload) check this before committing their result — if
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
logger: Logger,
|
||||
config: ProfileConfig,
|
||||
defaultNickname: string,
|
||||
) {
|
||||
this.tsClient = tsClient;
|
||||
this.logger = logger.child({ component: "profile" });
|
||||
this.config = { ...config };
|
||||
this.defaultNickname = defaultNickname;
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/**
|
||||
* Set/clear the persistent idle avatar. Pass null to remove.
|
||||
*
|
||||
* If the bot is currently in an idle state (no song playing OR
|
||||
* avatarEnabled is off), the new buffer is pushed to TS3 right away;
|
||||
* otherwise the cover-art sync is in charge until the next stop event,
|
||||
* at which point clearAvatar restores from this.customAvatar.
|
||||
*/
|
||||
setCustomAvatar(buffer: Buffer | null): void {
|
||||
this.customAvatar = buffer;
|
||||
const idle = this.currentSong === null || !this.config.avatarEnabled;
|
||||
if (!idle) return;
|
||||
const gen = ++this.generation;
|
||||
if (buffer && buffer.length > 0) {
|
||||
void this.applyIdleAvatar(gen);
|
||||
} else {
|
||||
void this.clearAvatar(gen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new song starts playing (song != null) or playback
|
||||
* stops (song == null).
|
||||
*
|
||||
* Commands are serialized to avoid overwhelming the TS3 command queue.
|
||||
* Nickname + away status are merged into a single `clientupdate` call.
|
||||
*
|
||||
* A generation counter guards against stale updates: if a newer
|
||||
* onSongChange fires while the avatar is still downloading, the old
|
||||
* update is discarded.
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
this.currentSong = song;
|
||||
|
||||
// 1. Avatar first — file transfer uses its own response tracker and
|
||||
// must run before sendCommandNoWait calls whose orphaned responses
|
||||
// could confuse the command matcher.
|
||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||
if (this.generation !== gen) return; // superseded
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.currentSong = null;
|
||||
this.permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
// No song is playing on a fresh connect, so the matrix says the
|
||||
// custom avatar should be visible regardless of avatarEnabled.
|
||||
if (this.customAvatar) {
|
||||
const gen = this.generation;
|
||||
void this.applyIdleAvatar(gen);
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): ProfileConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(partial: Partial<ProfileConfig>): void {
|
||||
Object.assign(this.config, partial);
|
||||
}
|
||||
|
||||
// --- Internal update methods ---
|
||||
|
||||
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
|
||||
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
|
||||
try {
|
||||
if (!coverUrl) {
|
||||
await this.clearAvatar(gen);
|
||||
return;
|
||||
}
|
||||
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
|
||||
const thumbUrl = this.thumbnailUrl(coverUrl);
|
||||
const imageBuffer = await this.downloadImage(thumbUrl);
|
||||
|
||||
// Check generation after the slow download — bail if superseded.
|
||||
if (this.generation !== gen) return;
|
||||
|
||||
if (!imageBuffer || imageBuffer.length === 0) return;
|
||||
if (imageBuffer.length > AVATAR_MAX_BYTES) {
|
||||
this.logger.warn(
|
||||
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
|
||||
"Cover image still too large after resize — skipping avatar update",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap the file-transfer sequence with a timeout — the TS3
|
||||
// full-client file transfer can silently hang.
|
||||
const start = Date.now();
|
||||
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
||||
this.logger.info(
|
||||
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
|
||||
"Avatar updated",
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-step upload. Each step is logged so the log can tell us whether
|
||||
* a broken/loading avatar on the client is from:
|
||||
* (a) init failing (no permission)
|
||||
* (b) file transfer hanging on TCP 30033
|
||||
* (c) client_flag_avatar not applying
|
||||
* If (b) happens, the avatar MD5 would still be set in the past — leaving
|
||||
* clients showing a placeholder. The flag is now only set after the TCP
|
||||
* transfer resolves.
|
||||
*/
|
||||
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
||||
const host = this.tsClient.getHost();
|
||||
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
|
||||
const info = await this.tsClient.fileTransferInitUpload(
|
||||
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
||||
);
|
||||
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
|
||||
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
||||
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
||||
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
||||
}
|
||||
|
||||
private async clearAvatar(gen: number): Promise<void> {
|
||||
if (this.customAvatar && this.customAvatar.length > 0) {
|
||||
await this.applyIdleAvatar(gen);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.withTimeout(
|
||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||
FILE_TRANSFER_TIMEOUT_MS,
|
||||
);
|
||||
} catch {
|
||||
// File may not exist or transfer timed out — that's fine
|
||||
}
|
||||
if (this.generation !== gen) return;
|
||||
try {
|
||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyIdleAvatar(gen: number): Promise<void> {
|
||||
if (!this.customAvatar || this.customAvatar.length === 0) return;
|
||||
if (this.permDenied.avatar) return;
|
||||
try {
|
||||
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
|
||||
if (this.generation !== gen) return;
|
||||
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
|
||||
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
|
||||
// be misreported as success.
|
||||
const result = await httpQuery.clientUpdate({ client_description: text });
|
||||
this.logger.info({ status: result.status }, "Description updated");
|
||||
} else {
|
||||
// clientupdate rejects client_description (error 1538).
|
||||
// Use clientedit on our own clid instead — this is what
|
||||
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||
const clid = this.tsClient.getClientId();
|
||||
if (clid <= 0) return;
|
||||
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||
// remaining profile updates (channeledit, now-playing msg).
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
this.logger.info("Description updated");
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send a single `clientupdate` command that sets nickname
|
||||
* and away status together, avoiding multiple round-trips that can
|
||||
* cause command-queue timeouts on the TS3 protocol.
|
||||
*
|
||||
* Values are collected as raw strings/numbers. The TS6 HTTP path
|
||||
* forwards them as JSON (the server expects real spaces, not `\s`);
|
||||
* the TS3 wire path escapes them on the fly. Previously the code
|
||||
* escaped upfront and then split the escaped string to build the
|
||||
* JSON body, so TS6 received literal backslashes and silently
|
||||
* rejected the update.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
const rawProps: Record<string, string | number> = {};
|
||||
|
||||
// --- Nickname ---
|
||||
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
||||
if (!song) {
|
||||
rawProps.client_nickname = this.defaultNickname;
|
||||
} else {
|
||||
const nickname = this.buildNickname(song);
|
||||
if (nickname) {
|
||||
rawProps.client_nickname = nickname;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Away status ---
|
||||
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
||||
if (song) {
|
||||
rawProps.client_away = 0;
|
||||
} else {
|
||||
rawProps.client_away = 1;
|
||||
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rawProps).length === 0) return;
|
||||
|
||||
try {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
|
||||
const result = await httpQuery.clientUpdate(rawProps);
|
||||
this.logger.info(
|
||||
{ status: result.status, props: Object.keys(rawProps) },
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
} else {
|
||||
// TS3 wire protocol: escape string values inline.
|
||||
// sendCommandNoWait: the TS3 full-client protocol often
|
||||
// doesn't return a timely error response for clientupdate,
|
||||
// causing execCommand to time out after 10s.
|
||||
const parts = Object.entries(rawProps).map(([k, v]) =>
|
||||
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
|
||||
);
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||
this.logger.info(
|
||||
{ props: Object.keys(rawProps) },
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Flag both features on permission error
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nickname string that fits within TS3_NICKNAME_MAX.
|
||||
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
|
||||
* not characters.
|
||||
*/
|
||||
private buildNickname(song: QueuedSong): string | null {
|
||||
const songInfo = `${song.name} - ${song.artist}`;
|
||||
const prefix = "\u266A "; // ♪
|
||||
const sep = " - ";
|
||||
const suffix = `${sep}${this.defaultNickname}`;
|
||||
|
||||
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
|
||||
if (overheadBytes >= TS3_NICKNAME_MAX) {
|
||||
// Default nickname alone is too long with decoration — skip
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
|
||||
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
|
||||
return `${prefix}${truncated}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
|
||||
* Appends an ellipsis if truncation occurred, taking its byte cost
|
||||
* into account. Never splits a multi-byte character.
|
||||
*/
|
||||
private truncateUtf8(str: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
|
||||
const ellipsis = "\u2026"; // …
|
||||
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
|
||||
const target = maxBytes - ellipsisBytes;
|
||||
if (target <= 0) return ellipsis;
|
||||
// Walk characters, accumulating byte length
|
||||
let byteLen = 0;
|
||||
let end = 0;
|
||||
for (const ch of str) {
|
||||
const chBytes = Buffer.byteLength(ch, "utf8");
|
||||
if (byteLen + chBytes > target) break;
|
||||
byteLen += chBytes;
|
||||
end += ch.length; // ch.length handles surrogate pairs
|
||||
}
|
||||
return str.slice(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
try {
|
||||
const channelId = this.tsClient.getChannelId();
|
||||
if (channelId === 0n) return; // unknown channel
|
||||
|
||||
if (!song) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||
];
|
||||
const desc = lines.join("\\n");
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
|
||||
await this.tsClient.sendTextMessage(text);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("nowPlayingMsg", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
* BiliBili and YouTube covers fall through to the size-check guard.
|
||||
*/
|
||||
private thumbnailUrl(url: string): string {
|
||||
if (url.includes("music.126.net") || url.includes("netease")) {
|
||||
return url.includes("?") ? url : `${url}?param=200y200`;
|
||||
}
|
||||
if (url.includes("qqmusic") || url.includes("qq.com")) {
|
||||
return url.replace(/\/\d+$/, "/200");
|
||||
}
|
||||
if (url.includes("bilivideo") || url.includes("hdslb")) {
|
||||
// BiliBili CDN supports @<w>w_<h>h suffix
|
||||
return url.includes("@") ? url : `${url}@200w_200h`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private async downloadImage(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const resp = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 8000,
|
||||
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
|
||||
});
|
||||
return Buffer.from(resp.data);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, url }, "Failed to download cover image");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Race a promise against a timeout. */
|
||||
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleFeatureError(
|
||||
feature: keyof typeof this.permDenied,
|
||||
err: unknown,
|
||||
): void {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
const status = err instanceof HttpQueryError ? err.status : undefined;
|
||||
const body = err instanceof HttpQueryError ? err.body : undefined;
|
||||
// Disable the feature for this session on unrecoverable errors:
|
||||
// - permission / insufficient → server denies the action
|
||||
// - invalid parameter → command not supported by this protocol
|
||||
// - HTTP 401/403 → TS6 server rejects the API key/role
|
||||
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
|
||||
const isUnrecoverable =
|
||||
msg.includes("permission") ||
|
||||
msg.includes("insufficient") ||
|
||||
msg.includes("invalid parameter") ||
|
||||
status === 400 ||
|
||||
status === 401 ||
|
||||
status === 403;
|
||||
if (isUnrecoverable) {
|
||||
this.permDenied[feature] = true;
|
||||
this.logger.info(
|
||||
{ feature, status, body, reason: msg },
|
||||
"Feature disabled for this session (will retry after reconnect)",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn({ feature, status, body, err }, "Profile update failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||
|
||||
describe("parseSongRef (#90 exact-song selection)", () => {
|
||||
it("returns null for a plain search term", () => {
|
||||
expect(parseSongRef("Die For You")).toBeNull();
|
||||
expect(parseSongRef("周杰伦 晴天")).toBeNull();
|
||||
expect(parseSongRef("")).toBeNull();
|
||||
// A bare number is NOT treated as an id (a song may be named "2002").
|
||||
expect(parseSongRef("2002")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
|
||||
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
|
||||
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
|
||||
});
|
||||
|
||||
it("strips trailing punctuation from a pasted id:", () => {
|
||||
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
|
||||
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
|
||||
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
|
||||
});
|
||||
|
||||
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
|
||||
// These reuse ?id= but are not songs — they should fall through to search,
|
||||
// not misresolve to getSongDetail(collectionId) and error "no song".
|
||||
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
|
||||
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
|
||||
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
|
||||
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
|
||||
// A genuine song URL is still parsed.
|
||||
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
|
||||
});
|
||||
|
||||
it("parses NetEase song URLs", () => {
|
||||
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
|
||||
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
|
||||
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
|
||||
});
|
||||
|
||||
it("parses QQ song URLs", () => {
|
||||
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
|
||||
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
|
||||
});
|
||||
|
||||
it("parses BiliBili BV ids (bare or in a URL)", () => {
|
||||
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSelectionIndex (#90 pick from last search)", () => {
|
||||
it("parses #N tokens (1-based)", () => {
|
||||
expect(parseSelectionIndex("#1")).toBe(1);
|
||||
expect(parseSelectionIndex("#2")).toBe(2);
|
||||
expect(parseSelectionIndex("# 3")).toBe(3);
|
||||
expect(parseSelectionIndex(" #10 ")).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects non-selections", () => {
|
||||
expect(parseSelectionIndex("2")).toBeNull();
|
||||
expect(parseSelectionIndex("#0")).toBeNull();
|
||||
expect(parseSelectionIndex("#-1")).toBeNull();
|
||||
expect(parseSelectionIndex("Die For You")).toBeNull();
|
||||
expect(parseSelectionIndex("#2 extra")).toBeNull();
|
||||
expect(parseSelectionIndex("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
|
||||
* so same-name songs can be disambiguated instead of always getting the single
|
||||
* most-popular search hit (issue #90).
|
||||
*
|
||||
* Two mechanisms:
|
||||
* - A song reference: an explicit id / platform URL → play that exact song.
|
||||
* - A selection index: "#N" → the Nth result of the previous !search.
|
||||
*/
|
||||
|
||||
export interface SongRef {
|
||||
id: string;
|
||||
/**
|
||||
* Platform inferred from a URL. `null` means the platform wasn't encoded in
|
||||
* the reference (e.g. a bare `id:`), so the caller should fall back to the
|
||||
* command's flags / default provider.
|
||||
*/
|
||||
platform: "netease" | "qq" | "bilibili" | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an explicit song reference in a query. Recognizes:
|
||||
* - `id:<id>` → platform from flags/default
|
||||
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
|
||||
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
|
||||
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
|
||||
* Returns `null` for a plain search term (the common case).
|
||||
*/
|
||||
export function parseSongRef(raw: string): SongRef | null {
|
||||
const q = (raw ?? "").trim();
|
||||
if (!q) return null;
|
||||
|
||||
// Explicit "id:<id>" — platform decided by the command's flags/default.
|
||||
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
|
||||
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
|
||||
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
|
||||
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
|
||||
|
||||
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
|
||||
// a "BV..." token never collides with them).
|
||||
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
|
||||
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
|
||||
return { id: bv[0], platform: "bilibili" };
|
||||
}
|
||||
|
||||
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
|
||||
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
|
||||
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
|
||||
// confusing "no song" error instead of falling back to a normal search.
|
||||
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
|
||||
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
|
||||
if (m) return { id: m[1], platform: "netease" };
|
||||
}
|
||||
|
||||
// QQ song URL.
|
||||
if (/y\.qq\.com/i.test(q)) {
|
||||
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
|
||||
if (m) return { id: m[1], platform: "qq" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a "#N" selection token (1-based) referencing the previous !search.
|
||||
* Returns the positive integer, or `null` when the query isn't a selection.
|
||||
*/
|
||||
export function parseSelectionIndex(raw: string): number | null {
|
||||
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createAuditStore, type AuditStore } from "./audit.js";
|
||||
|
||||
describe("AuditStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let audit: AuditStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
audit = createAuditStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
it("records and lists entries newest-first", async () => {
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: "b1", targetUsername: "bob",
|
||||
action: "user.created",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: "b1", targetUsername: "bob",
|
||||
action: "user.deleted",
|
||||
});
|
||||
const list = audit.list(10, 0);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].action).toBe("user.deleted");
|
||||
expect(list[1].action).toBe("user.created");
|
||||
});
|
||||
|
||||
it("supports limit and offset", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
audit.record({
|
||||
actorId: "a1", actorUsername: "alice",
|
||||
targetUserId: null, targetUsername: null,
|
||||
action: "user.password_changed",
|
||||
});
|
||||
}
|
||||
expect(audit.list(2, 0)).toHaveLength(2);
|
||||
expect(audit.list(2, 4)).toHaveLength(1);
|
||||
expect(audit.list(10, 10)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stores nullable fields correctly", () => {
|
||||
audit.record({
|
||||
actorId: null, actorUsername: null,
|
||||
targetUserId: "x", targetUsername: "deleted-user",
|
||||
action: "admin.first_created",
|
||||
});
|
||||
const e = audit.list(1, 0)[0];
|
||||
expect(e.actorId).toBeNull();
|
||||
expect(e.actorUsername).toBeNull();
|
||||
expect(e.targetUserId).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export type AuditAction =
|
||||
| "admin.first_created"
|
||||
| "user.created"
|
||||
| "user.deleted"
|
||||
| "user.password_reset"
|
||||
| "user.password_changed"
|
||||
| "user.role_changed"
|
||||
| "user.permissions_changed";
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
actorId: string | null;
|
||||
actorUsername: string | null;
|
||||
targetUserId: string | null;
|
||||
targetUsername: string | null;
|
||||
action: AuditAction;
|
||||
}
|
||||
|
||||
export interface AuditRecordInput {
|
||||
actorId: string | null;
|
||||
actorUsername: string | null;
|
||||
targetUserId: string | null;
|
||||
targetUsername: string | null;
|
||||
action: AuditAction;
|
||||
}
|
||||
|
||||
export interface AuditStore {
|
||||
record(input: AuditRecordInput): void;
|
||||
list(limit: number, offset: number): AuditEntry[];
|
||||
}
|
||||
|
||||
export function createAuditStore(db: Database.Database): AuditStore {
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const listStmt = db.prepare(
|
||||
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
|
||||
return {
|
||||
record(input) {
|
||||
insertStmt.run(
|
||||
Date.now(),
|
||||
input.actorId,
|
||||
input.actorUsername,
|
||||
input.targetUserId,
|
||||
input.targetUsername,
|
||||
input.action
|
||||
);
|
||||
},
|
||||
list(limit, offset) {
|
||||
return listStmt.all(limit, offset) as AuditEntry[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAvatarStore } from "./avatars.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
|
||||
});
|
||||
|
||||
describe("createAvatarStore", () => {
|
||||
it("write returns a relative path under the store dir", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
const buf = Buffer.from("fake-png");
|
||||
const rel = store.write("bot-1", "image/png", buf);
|
||||
expect(rel).toBe("bot-1.png");
|
||||
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
|
||||
});
|
||||
|
||||
it("write picks correct extension for jpeg / webp", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
|
||||
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
|
||||
});
|
||||
|
||||
it("write rejects unsupported MIME types", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i);
|
||||
});
|
||||
|
||||
it("read returns the bytes for an existing file", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("hello"));
|
||||
const buf = store.read("bot-1.png");
|
||||
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
|
||||
});
|
||||
|
||||
it("read returns null when path is missing", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
expect(store.read("missing.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("remove deletes the file (idempotent)", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("x"));
|
||||
store.remove("bot-1.png");
|
||||
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||
expect(() => store.remove("bot-1.png")).not.toThrow();
|
||||
});
|
||||
|
||||
it("write replaces any existing file for the same botId regardless of old extension", () => {
|
||||
const store = createAvatarStore(dir);
|
||||
store.write("bot-1", "image/png", Buffer.from("old"));
|
||||
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
|
||||
expect(rel).toBe("bot-1.jpg");
|
||||
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
export interface AvatarStore {
|
||||
/** Returns the relative path written (e.g. "bot-1.png"). */
|
||||
write(botId: string, mime: string, buffer: Buffer): string;
|
||||
read(relPath: string): Buffer | null;
|
||||
remove(relPath: string): void;
|
||||
getDir(): string;
|
||||
}
|
||||
|
||||
export function createAvatarStore(dir: string): AvatarStore {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return {
|
||||
write(botId, mime, buffer) {
|
||||
const ext = MIME_TO_EXT[mime];
|
||||
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
|
||||
}
|
||||
const rel = `${botId}.${ext}`;
|
||||
writeFileSync(join(dir, rel), buffer);
|
||||
return rel;
|
||||
},
|
||||
read(relPath) {
|
||||
const full = join(dir, relPath);
|
||||
if (!existsSync(full)) return null;
|
||||
return readFileSync(full);
|
||||
},
|
||||
remove(relPath) {
|
||||
rmSync(join(dir, relPath), { force: true });
|
||||
},
|
||||
getDir() {
|
||||
return dir;
|
||||
},
|
||||
};
|
||||
}
|
||||
+580
-4
@@ -1,8 +1,31 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
|
||||
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig, defaultPlatform } from "./config.js";
|
||||
|
||||
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
|
||||
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
|
||||
// rmSync, existsSync, …) is the real implementation via `...actual`, so all other
|
||||
// tests keep their real filesystem behavior. `vi.spyOn` can't be used here because
|
||||
// the node:fs ESM namespace is non-configurable in this setup.
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: vi.fn(actual.readFileSync),
|
||||
writeFileSync: vi.fn(actual.writeFileSync),
|
||||
renameSync: vi.fn(actual.renameSync),
|
||||
};
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
const dirs: string[] = [];
|
||||
@@ -25,6 +48,159 @@ describe("config", () => {
|
||||
expect(config).toEqual(getDefaultConfig());
|
||||
});
|
||||
|
||||
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||
expect(config.enabledProviders).not.toContain("jellyfin");
|
||||
});
|
||||
|
||||
it("keeps pre-gating behavior for legacy configs without enabledProviders", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
// A config written before enabledProviders existed: no such field.
|
||||
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
|
||||
const config = loadConfig(path);
|
||||
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
|
||||
expect(defaultPlatform(config)).toBe("netease");
|
||||
});
|
||||
|
||||
it("defaultPlatform follows the fixed priority order", () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(defaultPlatform(config)).toBe("netease");
|
||||
|
||||
// Jellyfin ranks after the online music platforms…
|
||||
config.enabledProviders = ["netease", "jellyfin"];
|
||||
expect(defaultPlatform(config)).toBe("netease");
|
||||
// …but ahead of the video sites…
|
||||
config.enabledProviders = ["bilibili", "jellyfin", "youtube"];
|
||||
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||
// …and is the default when it is the only enabled source.
|
||||
config.enabledProviders = ["jellyfin"];
|
||||
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||
// Nothing enabled → netease fallback (the gate then reports it disabled).
|
||||
config.enabledProviders = [];
|
||||
expect(defaultPlatform(config)).toBe("netease");
|
||||
});
|
||||
|
||||
// --- #126: an explicit operator default source ---
|
||||
|
||||
it("defaultPlatform is null by default (follow the priority order)", () => {
|
||||
expect(getDefaultConfig().defaultPlatform).toBeNull();
|
||||
});
|
||||
|
||||
it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => {
|
||||
const config = getDefaultConfig();
|
||||
// Priority would pick netease; a Bilibili-loving server sets B站 instead (#126).
|
||||
config.defaultPlatform = "bilibili";
|
||||
expect(defaultPlatform(config)).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("defaultPlatform() ignores a preference whose source is not enabled", () => {
|
||||
const config = getDefaultConfig();
|
||||
config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config
|
||||
// Falls back to the fixed priority order (netease)…
|
||||
expect(defaultPlatform(config)).toBe("netease");
|
||||
// …until the preferred source is actually enabled.
|
||||
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
|
||||
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||
});
|
||||
|
||||
it("loadConfig keeps a valid, enabled defaultPlatform", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" }));
|
||||
const config = loadConfig(path);
|
||||
expect(config.defaultPlatform).toBe("bilibili");
|
||||
expect(defaultPlatform(config)).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => {
|
||||
const dir = makeTmpDir();
|
||||
// Unknown provider name.
|
||||
const p1 = join(dir, "c1.json");
|
||||
writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" }));
|
||||
expect(loadConfig(p1).defaultPlatform).toBeNull();
|
||||
// Known provider, but not in enabledProviders.
|
||||
const p2 = join(dir, "c2.json");
|
||||
writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" }));
|
||||
expect(loadConfig(p2).defaultPlatform).toBeNull();
|
||||
// Wrong type.
|
||||
const p3 = join(dir, "c3.json");
|
||||
writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 }));
|
||||
expect(loadConfig(p3).defaultPlatform).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips defaultPlatform through save/load", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" });
|
||||
expect(loadConfig(path).defaultPlatform).toBe("qq");
|
||||
});
|
||||
|
||||
it("respects an explicit jellyfin-only enabledProviders from disk", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
// e.g. a config persisted by the short-lived jellyfin-by-default builds.
|
||||
writeFileSync(path, JSON.stringify({ enabledProviders: ["jellyfin"] }));
|
||||
const config = loadConfig(path);
|
||||
expect(config.enabledProviders).toEqual(["jellyfin"]);
|
||||
expect(defaultPlatform(config)).toBe("jellyfin");
|
||||
});
|
||||
|
||||
// ── audioQuality persistence (#125) ─────────────────────────────────────
|
||||
it("defaults audioQuality to each provider's in-memory default", () => {
|
||||
const config = getDefaultConfig();
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "exhigh",
|
||||
qq: "exhigh",
|
||||
bilibili: "high",
|
||||
kugou: "128",
|
||||
jellyfin: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("fills audioQuality defaults for a legacy config without the field", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
|
||||
const config = loadConfig(path);
|
||||
expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality);
|
||||
});
|
||||
|
||||
it("round-trips a saved audioQuality through save/load", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
const config = getDefaultConfig();
|
||||
config.audioQuality = {
|
||||
netease: "lossless",
|
||||
qq: "flac",
|
||||
bilibili: "high",
|
||||
kugou: "flac",
|
||||
jellyfin: "320",
|
||||
};
|
||||
saveConfig(path, config);
|
||||
const loaded = loadConfig(path);
|
||||
expect(loaded.audioQuality).toEqual(config.audioQuality);
|
||||
});
|
||||
|
||||
it("coerces missing / non-string audioQuality fields to defaults", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
// netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid.
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }),
|
||||
);
|
||||
const config = loadConfig(path);
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "lossless",
|
||||
qq: "exhigh", // blank → default
|
||||
bilibili: "high", // non-string → default
|
||||
kugou: "128", // missing → default
|
||||
jellyfin: "192",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates config file on save", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "sub", "config.json");
|
||||
@@ -49,6 +225,406 @@ describe("config", () => {
|
||||
// defaults should fill in the rest
|
||||
expect(loaded.theme).toBe("dark");
|
||||
expect(loaded.commandPrefix).toBe("!");
|
||||
expect(loaded.autoPauseOnEmpty).toBe(true);
|
||||
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
|
||||
expect(loaded.autoPauseOnEmpty).toBe(false);
|
||||
});
|
||||
|
||||
// --- #86: config.json must live under (and be created in) the persisted data dir ---
|
||||
|
||||
it("first run writes config.json into the data dir and reads it back", () => {
|
||||
const root = makeTmpDir();
|
||||
const dataDir = join(root, "data");
|
||||
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
|
||||
|
||||
// Boot sequence: load (missing -> defaults) then save.
|
||||
const config = loadConfig(configPath);
|
||||
saveConfig(configPath, config);
|
||||
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
// A subsequent hand-edited file under the SAME persisted path is honored.
|
||||
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
|
||||
expect(loadConfig(configPath).webPort).toBe(9999);
|
||||
});
|
||||
|
||||
it("migrates a legacy root config into the data dir, preserving values", () => {
|
||||
const root = makeTmpDir();
|
||||
const legacyPath = join(root, "config.json");
|
||||
const newPath = join(root, "data", "config.json");
|
||||
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
|
||||
|
||||
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||
|
||||
expect(migrated).toBe(true);
|
||||
expect(existsSync(newPath)).toBe(true);
|
||||
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
|
||||
const loaded = loadConfig(newPath);
|
||||
expect(loaded.webPort).toBe(4242);
|
||||
expect(loaded.publicUrl).toBe("http://x");
|
||||
});
|
||||
|
||||
it("does NOT overwrite an existing data-dir config during migration", () => {
|
||||
const root = makeTmpDir();
|
||||
const legacyPath = join(root, "config.json");
|
||||
const newPath = join(root, "data", "config.json");
|
||||
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
|
||||
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
|
||||
|
||||
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||
|
||||
expect(migrated).toBe(false); // new location wins, untouched
|
||||
expect(loadConfig(newPath).webPort).toBe(2222);
|
||||
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
|
||||
});
|
||||
|
||||
it("migration is a no-op when there is no legacy config", () => {
|
||||
const root = makeTmpDir();
|
||||
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
|
||||
expect(migrated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guestMode config", () => {
|
||||
it("defaults to disabled, all-bots, append-only", () => {
|
||||
const c = getDefaultConfig();
|
||||
expect(c.guestMode.enabled).toBe(false);
|
||||
expect(c.guestMode.bots).toBe("all");
|
||||
expect(c.guestMode.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } }));
|
||||
const c = loadConfig(p);
|
||||
expect(c.guestMode.enabled).toBe(true);
|
||||
expect(c.guestMode.bots).toBe("all"); // back-filled
|
||||
expect(c.guestMode.permissions.playNext).toBe(true);
|
||||
expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default
|
||||
expect(c.guestMode.permissions.skip).toBe(false); // back-filled default
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode ---
|
||||
|
||||
function loadGuestMode(raw: unknown) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify(raw));
|
||||
try {
|
||||
return loadConfig(p).guestMode;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("bots normalization", () => {
|
||||
it("a numeric bots value falls back to the default \"all\" (no crash)", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: 5 } });
|
||||
expect(gm.bots).toBe("all");
|
||||
});
|
||||
it("an array bots value is filtered to strings only", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } });
|
||||
expect(gm.bots).toEqual(["a", "b"]);
|
||||
});
|
||||
it("the literal \"all\" is preserved", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: "all" } });
|
||||
expect(gm.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions coercion", () => {
|
||||
it("a non-boolean truthy flag is coerced to false; a real true stays true", () => {
|
||||
const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } });
|
||||
expect(gm.permissions.skip).toBe(false);
|
||||
expect(gm.permissions.playNext).toBe(true);
|
||||
});
|
||||
it("a string permissions value yields defaults with no numeric index keys", () => {
|
||||
const gm = loadGuestMode({ guestMode: { permissions: "hacked" } });
|
||||
// all known flags present at their defaults
|
||||
expect(gm.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
});
|
||||
// no garbage index keys leaked from spreading a string
|
||||
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminGroups normalization", () => {
|
||||
function loadAdminGroups(raw: unknown) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify(raw));
|
||||
try {
|
||||
return loadConfig(p).adminGroups;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("defaults to [] when absent", () => {
|
||||
expect(loadAdminGroups({})).toEqual([]);
|
||||
});
|
||||
it("keeps valid non-negative integers", () => {
|
||||
expect(loadAdminGroups({ adminGroups: [6, 8] })).toEqual([6, 8]);
|
||||
});
|
||||
it("filters out negatives, non-integers and non-numbers", () => {
|
||||
expect(loadAdminGroups({ adminGroups: [6, -1, 2.5, "8", null] })).toEqual([6]);
|
||||
});
|
||||
it("a non-array value falls back to the default [] (no crash)", () => {
|
||||
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotify config", () => {
|
||||
it("defaults are present and disabled", () => {
|
||||
const c = getDefaultConfig();
|
||||
expect(c.spotify).toEqual({
|
||||
enabled: false,
|
||||
backend: "auto",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
deviceName: "TSMusicBot",
|
||||
bitrate: 320,
|
||||
});
|
||||
});
|
||||
|
||||
it("loadConfig coerces bad spotify values back to safe defaults", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(
|
||||
p,
|
||||
JSON.stringify({
|
||||
spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 },
|
||||
})
|
||||
);
|
||||
const c = loadConfig(p);
|
||||
expect(c.spotify.enabled).toBe(false); // non-boolean → false
|
||||
expect(c.spotify.backend).toBe("auto"); // invalid enum → auto
|
||||
expect(c.spotify.bitrate).toBe(320); // invalid → 320
|
||||
expect(c.spotify.clientId).toBe(""); // non-string → ""
|
||||
expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default
|
||||
});
|
||||
|
||||
it("loadConfig preserves valid spotify values", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(
|
||||
p,
|
||||
JSON.stringify({
|
||||
spotify: {
|
||||
enabled: true,
|
||||
backend: "librespot",
|
||||
clientId: "abc",
|
||||
clientSecret: "def",
|
||||
deviceName: "MyBot",
|
||||
bitrate: 160,
|
||||
},
|
||||
})
|
||||
);
|
||||
const c = loadConfig(p);
|
||||
expect(c.spotify).toEqual({
|
||||
enabled: true,
|
||||
backend: "librespot",
|
||||
clientId: "abc",
|
||||
clientSecret: "def",
|
||||
deviceName: "MyBot",
|
||||
bitrate: 160,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- R2-1: saveConfig must write atomically (temp file + rename), never truncate ---
|
||||
|
||||
describe("saveConfig atomic write", () => {
|
||||
const dirs: string[] = [];
|
||||
function makeTmpDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-atomic-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); // reset call history, keep the call-through implementations
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("round-trips (save then load equals) and leaves NO .tmp file behind", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
const config = { ...getDefaultConfig(), webPort: 4567, adminPassword: "pw" };
|
||||
|
||||
saveConfig(path, config);
|
||||
|
||||
expect(loadConfig(path)).toEqual(config);
|
||||
// No temp remnants in the target directory.
|
||||
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes via a same-dir temp file then renameSync onto the final path", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
|
||||
saveConfig(path, getDefaultConfig());
|
||||
|
||||
expect(vi.mocked(renameSync)).toHaveBeenCalled();
|
||||
const [from, to] = vi.mocked(renameSync).mock.calls[0] as [string, string];
|
||||
expect(to).toBe(path); // renamed ONTO the real path
|
||||
expect(String(from)).not.toBe(path); // ...from a distinct temp file
|
||||
expect(join(String(from), "..")).toBe(join(path, "..")); // ...in the SAME directory
|
||||
});
|
||||
|
||||
it("does not corrupt a pre-existing valid config when saving over it", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
saveConfig(path, { ...getDefaultConfig(), adminPassword: "first", webPort: 1234 });
|
||||
|
||||
// Overwrite with a different, fully-formed config.
|
||||
saveConfig(path, { ...getDefaultConfig(), adminPassword: "second", webPort: 9999 });
|
||||
|
||||
const loaded = loadConfig(path);
|
||||
expect(loaded.adminPassword).toBe("second");
|
||||
expect(loaded.webPort).toBe(9999);
|
||||
// The on-disk file is a single complete JSON document (no partial/truncated write).
|
||||
expect(() => JSON.parse(readFileSync(path, "utf-8"))).not.toThrow();
|
||||
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans up the temp file (no .tmp remnant) when the rename fails", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
vi.mocked(renameSync).mockImplementationOnce(() => {
|
||||
throw new Error("rename boom");
|
||||
});
|
||||
|
||||
expect(() => saveConfig(path, getDefaultConfig())).toThrow(/rename boom/);
|
||||
// The failed write left no temp file lying around.
|
||||
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- R2-2: loadConfig must not treat a transient/corrupt read as "missing" ---
|
||||
|
||||
describe("loadConfig error handling", () => {
|
||||
const dirs: string[] = [];
|
||||
function makeTmpDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-load-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("(a) ENOENT (missing file) returns defaults — unchanged first-run behavior", () => {
|
||||
const dir = makeTmpDir();
|
||||
expect(loadConfig(join(dir, "config.json"))).toEqual(getDefaultConfig());
|
||||
});
|
||||
|
||||
it("(b) a non-ENOENT read error (EBUSY) rethrows instead of returning defaults", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
// A REAL config exists on disk; a transient lock must NOT collapse to defaults
|
||||
// (the caller would otherwise overwrite this real config with defaults).
|
||||
saveConfig(path, { ...getDefaultConfig(), adminPassword: "keep-me" });
|
||||
vi.mocked(readFileSync).mockImplementationOnce(() => {
|
||||
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
|
||||
err.code = "EBUSY";
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(() => loadConfig(path)).toThrow(/EBUSY/);
|
||||
// The on-disk config is untouched and still readable once the lock clears.
|
||||
expect(loadConfig(path).adminPassword).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("(c) corrupt JSON returns defaults AND backs up the original to *.corrupt-*", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
const garbage = "{ not: valid json, ";
|
||||
writeFileSync(path, garbage, "utf-8");
|
||||
|
||||
const loaded = loadConfig(path);
|
||||
|
||||
expect(loaded).toEqual(getDefaultConfig());
|
||||
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
|
||||
expect(backups.length).toBeGreaterThan(0);
|
||||
// The corrupt original is preserved verbatim (recoverable, never deleted).
|
||||
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(garbage);
|
||||
});
|
||||
|
||||
// (d)/(e) Valid JSON that is NOT a non-null object (null / [] / 42 / "str") passes
|
||||
// JSON.parse but would throw a raw TypeError in the per-field sanitize block
|
||||
// (property access on a non-object), bypassing the corrupt-backup path. It must be
|
||||
// treated EXACTLY like corrupt JSON: back up to *.corrupt-* (original preserved),
|
||||
// return defaults — NOT a thrown TypeError, and NOT a silent defaults-with-no-backup.
|
||||
it("(d) a `null` config is treated as corrupt: defaults + *.corrupt-* backup (original preserved)", () => {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
writeFileSync(path, "null", "utf-8");
|
||||
|
||||
let loaded: ReturnType<typeof getDefaultConfig>;
|
||||
expect(() => {
|
||||
loaded = loadConfig(path);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(loaded!).toEqual(getDefaultConfig());
|
||||
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
|
||||
expect(backups.length).toBeGreaterThan(0);
|
||||
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe("null");
|
||||
});
|
||||
|
||||
it("(e) a non-object config (`[]` / `42`) is backed up + defaults, not a thrown TypeError", () => {
|
||||
for (const content of ["[]", "42"]) {
|
||||
const dir = makeTmpDir();
|
||||
const path = join(dir, "config.json");
|
||||
writeFileSync(path, content, "utf-8");
|
||||
|
||||
let loaded: ReturnType<typeof getDefaultConfig>;
|
||||
expect(() => {
|
||||
loaded = loadConfig(path);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(loaded!).toEqual(getDefaultConfig());
|
||||
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
|
||||
expect(backups.length).toBeGreaterThan(0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Regular → Executable
+442
-6
@@ -1,5 +1,105 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
rmSync,
|
||||
renameSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { BotAccess, GuestPermissions } from "./permissions.js";
|
||||
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
|
||||
|
||||
export interface GuestModeConfig {
|
||||
enabled: boolean;
|
||||
bots: BotAccess; // "all" | string[]
|
||||
permissions: GuestPermissions;
|
||||
}
|
||||
|
||||
export interface SpotifyConfig {
|
||||
enabled: boolean;
|
||||
backend: "auto" | "go-librespot" | "librespot";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
deviceName: string;
|
||||
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 {
|
||||
webPort: number;
|
||||
@@ -13,6 +113,51 @@ export interface BotConfig {
|
||||
adminGroups: number[];
|
||||
autoReturnDelay: number;
|
||||
autoPauseOnEmpty: boolean;
|
||||
idleTimeoutMinutes: number;
|
||||
/** Enable uploading and playback of server-stored local audio files. */
|
||||
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专属链接).
|
||||
// Leave empty to use the browser's current origin. Example:
|
||||
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||
publicUrl: string;
|
||||
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
|
||||
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
|
||||
// behind HTTPS-terminating proxies.
|
||||
trustProxy: boolean;
|
||||
guestMode: GuestModeConfig;
|
||||
spotify: SpotifyConfig;
|
||||
jellyfin: JellyfinConfig;
|
||||
/** Persisted per-provider audio quality (音质), restored on startup (#125). */
|
||||
audioQuality: AudioQualityConfig;
|
||||
/**
|
||||
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
|
||||
* 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 {
|
||||
@@ -27,22 +172,313 @@ export function getDefaultConfig(): BotConfig {
|
||||
adminPassword: "",
|
||||
adminGroups: [],
|
||||
autoReturnDelay: 300,
|
||||
autoPauseOnEmpty: true,
|
||||
// Default OFF: occupancy detection relies on the full-client `clientlist`
|
||||
// command, which is unreliable on some servers (it can time out when other
|
||||
// clients are present). Users can opt in from the web UI.
|
||||
autoPauseOnEmpty: false,
|
||||
idleTimeoutMinutes: 0,
|
||||
localAudioEnabled: true,
|
||||
savedQueuesEnabled: false,
|
||||
playKeepsQueue: false,
|
||||
publicUrl: "",
|
||||
trustProxy: false,
|
||||
guestMode: {
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true,
|
||||
playNext: false,
|
||||
playNow: false,
|
||||
skip: false,
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
},
|
||||
spotify: {
|
||||
enabled: false,
|
||||
backend: "auto",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
deviceName: "TSMusicBot",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an unusable config aside to a timestamped `*.corrupt-*` backup so the data
|
||||
* stays recoverable (it is NEVER deleted), for both the corrupt-JSON case and the
|
||||
* parses-but-not-an-object case. Prefer an atomic same-dir rename; if that fails,
|
||||
* copy instead. If it can't be preserved at all, rethrow rather than let the caller
|
||||
* overwrite unrecoverable data.
|
||||
*/
|
||||
function backupCorruptConfig(path: string): void {
|
||||
const backup = `${path}.corrupt-${Date.now()}`;
|
||||
try {
|
||||
renameSync(path, backup);
|
||||
} catch {
|
||||
try {
|
||||
copyFileSync(path, backup);
|
||||
} catch (backupErr) {
|
||||
throw backupErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(path: string): BotConfig {
|
||||
const defaults = getDefaultConfig();
|
||||
|
||||
// Distinguish the three failure modes so a *real* on-disk config is NEVER
|
||||
// silently replaced with defaults (the caller saveConfig()s right after load,
|
||||
// which would otherwise erase spotify creds / adminPassword / adminGroups /
|
||||
// guestMode permanently):
|
||||
// (a) file ABSENT (ENOENT) — normal first run → defaults.
|
||||
// (b) any OTHER read error (EBUSY/EACCES/EPERM/EISDIR/…) on an existing file —
|
||||
// rethrow (fail-fast at boot). A loud crash beats silent credential loss.
|
||||
// (c) file readable but JSON.parse fails (corrupt) — back the file up first
|
||||
// (never delete it), THEN return defaults so boot can proceed.
|
||||
let raw: string;
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const partial = JSON.parse(raw) as Partial<BotConfig>;
|
||||
return { ...defaults, ...partial };
|
||||
raw = readFileSync(path, "utf-8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return defaults; // (a) missing file — first run
|
||||
}
|
||||
throw err; // (b) transient/permission error on an existing file — do not clobber it
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// (c) Corrupt content: move the unreadable file aside to a timestamped backup
|
||||
// so the data stays recoverable, then fall back to defaults.
|
||||
backupCorruptConfig(path);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// (d) Parses cleanly but is NOT a non-null object (e.g. `null`, `42`, `"str"`,
|
||||
// `[]`). The per-field sanitize below assumes an object and would throw a raw
|
||||
// TypeError (or silently spread junk), bypassing the corrupt-backup path. Treat
|
||||
// it EXACTLY like corrupt JSON: back it up (never delete), then return defaults.
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
backupCorruptConfig(path);
|
||||
return defaults;
|
||||
}
|
||||
const partial = parsed as Partial<BotConfig>;
|
||||
|
||||
{
|
||||
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
|
||||
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
|
||||
// directly — so coerce it here as well, mirroring that write-path logic.
|
||||
const partialGm = (partial.guestMode ?? {}) as Partial<GuestModeConfig>;
|
||||
const gm: GuestModeConfig = {
|
||||
...defaults.guestMode,
|
||||
...partialGm,
|
||||
// bots → "all" | string[]; anything else falls back to the default ("all").
|
||||
bots:
|
||||
partialGm.bots === "all"
|
||||
? "all"
|
||||
: Array.isArray(partialGm.bots)
|
||||
? partialGm.bots.filter((id): id is string => typeof id === "string")
|
||||
: defaults.guestMode.bots,
|
||||
// permissions → defaults, then spread ONLY a plain object, then strict-coerce
|
||||
// each known flag to a boolean (drops index keys + non-boolean values).
|
||||
permissions: { ...defaults.guestMode.permissions },
|
||||
};
|
||||
const partialPerms = partialGm.permissions;
|
||||
if (
|
||||
partialPerms !== null &&
|
||||
typeof partialPerms === "object" &&
|
||||
!Array.isArray(partialPerms)
|
||||
) {
|
||||
Object.assign(gm.permissions, partialPerms);
|
||||
}
|
||||
for (const f of GUEST_PERMISSION_FLAGS) {
|
||||
gm.permissions[f] = gm.permissions[f] === true;
|
||||
}
|
||||
|
||||
// Sanitize adminGroups on load too: the WebUI write path filters it, but a
|
||||
// hand-edited / legacy / corrupt config.json reaches the command gate
|
||||
// directly. Keep only non-negative integers; a non-array falls back to the
|
||||
// default []. Mirrors the guestMode sanitization above.
|
||||
const adminGroups = Array.isArray(partial.adminGroups)
|
||||
? partial.adminGroups.filter(
|
||||
(g): g is number => typeof g === "number" && Number.isInteger(g) && g >= 0,
|
||||
)
|
||||
: defaults.adminGroups;
|
||||
|
||||
const partialSp = (partial.spotify ?? {}) as Partial<SpotifyConfig>;
|
||||
const validBackends = ["auto", "go-librespot", "librespot"] as const;
|
||||
const validBitrates = [96, 160, 320];
|
||||
const spotify: SpotifyConfig = {
|
||||
enabled: partialSp.enabled === true,
|
||||
backend: (validBackends as readonly string[]).includes(partialSp.backend as string)
|
||||
? (partialSp.backend as SpotifyConfig["backend"])
|
||||
: defaults.spotify.backend,
|
||||
clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId,
|
||||
clientSecret:
|
||||
typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret,
|
||||
deviceName:
|
||||
typeof partialSp.deviceName === "string" && partialSp.deviceName.trim()
|
||||
? partialSp.deviceName
|
||||
: defaults.spotify.deviceName,
|
||||
bitrate: validBitrates.includes(partialSp.bitrate as number)
|
||||
? (partialSp.bitrate as number)
|
||||
: 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 {
|
||||
...defaults,
|
||||
...partial,
|
||||
adminGroups,
|
||||
guestMode: gm,
|
||||
spotify,
|
||||
jellyfin,
|
||||
audioQuality,
|
||||
enabledProviders,
|
||||
savedQueuesEnabled,
|
||||
playKeepsQueue,
|
||||
defaultPlatform: defaultPlatformPref,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(path: string, config: BotConfig): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
|
||||
// Atomic write: serialize to a sibling temp file in the SAME directory, then
|
||||
// rename it onto the final path. rename is an atomic replace on POSIX and modern
|
||||
// Windows, so a crash / power loss / ENOSPC mid-write can never leave config.json
|
||||
// truncated — a reader always sees either the previous file or the fully-written
|
||||
// new one, never a partial. The temp lives in the same dir so the rename stays on
|
||||
// one filesystem (a cross-device rename would fail); pid + timestamp keep
|
||||
// concurrent writers from colliding on the temp name.
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
writeFileSync(tmp, json, "utf-8");
|
||||
renameSync(tmp, path);
|
||||
} catch (err) {
|
||||
// Never leave a partial temp file behind on failure.
|
||||
try {
|
||||
rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration for the config location fix (#86).
|
||||
*
|
||||
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
|
||||
* persisted data directory (the Docker volume is mounted at data/). That meant the
|
||||
* file never landed in the volume on first run and a manually-placed data/config.json
|
||||
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
|
||||
*
|
||||
* If a legacy root-level config exists and the new data-dir config does not yet exist,
|
||||
* move it so existing local installs keep their customized settings. Best-effort:
|
||||
* any failure is swallowed and loadConfig falls back to defaults.
|
||||
*
|
||||
* @returns true if a legacy config was migrated, false otherwise.
|
||||
*/
|
||||
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
|
||||
try {
|
||||
if (legacyPath === newPath) return false;
|
||||
if (existsSync(newPath)) return false; // new location already populated — leave it
|
||||
if (!existsSync(legacyPath)) return false; // nothing to migrate
|
||||
mkdirSync(dirname(newPath), { recursive: true });
|
||||
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
|
||||
try {
|
||||
rmSync(legacyPath);
|
||||
} catch {
|
||||
/* leave the legacy file if it can't be removed; the new one wins */
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+254
-2
@@ -1,5 +1,9 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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";
|
||||
|
||||
describe("database", () => {
|
||||
let botDb: BotDatabase;
|
||||
@@ -23,6 +27,30 @@ describe("database", () => {
|
||||
expect(names).toContain("bot_instances");
|
||||
});
|
||||
|
||||
it("creates users and sessions tables on init", () => {
|
||||
const tables = botDb.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = tables.map((t) => t.name);
|
||||
expect(names).toContain("users");
|
||||
expect(names).toContain("sessions");
|
||||
|
||||
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
||||
const userColNames = userCols.map((c) => c.name).sort();
|
||||
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
|
||||
|
||||
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
||||
const sessionColNames = sessionCols.map((c) => c.name).sort();
|
||||
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
|
||||
});
|
||||
|
||||
it("creates user_audit table on init", () => {
|
||||
const tables = botDb.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables.map((t) => t.name)).toContain("user_audit");
|
||||
});
|
||||
|
||||
it("records and retrieves play history", () => {
|
||||
botDb.addPlayHistory({
|
||||
botId: "bot1",
|
||||
@@ -32,6 +60,7 @@ describe("database", () => {
|
||||
album: "Test Album",
|
||||
platform: "netease",
|
||||
coverUrl: "https://example.com/cover.jpg",
|
||||
requestedBy: "alice",
|
||||
});
|
||||
|
||||
botDb.addPlayHistory({
|
||||
@@ -48,6 +77,7 @@ describe("database", () => {
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].songName).toBe("Another Song");
|
||||
expect(history[1].songName).toBe("Test Song");
|
||||
expect(history[1].requestedBy).toBe("alice");
|
||||
});
|
||||
|
||||
it("saves and loads bot instances", () => {
|
||||
@@ -58,14 +88,18 @@ describe("database", () => {
|
||||
serverPort: 9987,
|
||||
nickname: "MusicBot",
|
||||
defaultChannel: "Music",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: true,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
|
||||
botDb.saveBotInstance(instance);
|
||||
const instances = botDb.getBotInstances();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]).toEqual(instance);
|
||||
expect(instances[0]).toMatchObject(instance);
|
||||
expect(instances[0].autoStart).toBe(true);
|
||||
|
||||
// Test upsert
|
||||
@@ -84,12 +118,230 @@ describe("database", () => {
|
||||
serverPort: 9987,
|
||||
nickname: "MusicBot",
|
||||
defaultChannel: "Music",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
});
|
||||
|
||||
expect(botDb.deleteBotInstance("bot1")).toBe(true);
|
||||
expect(botDb.getBotInstances()).toHaveLength(0);
|
||||
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
|
||||
const inst = {
|
||||
id: "bot-ps",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
botDb.saveBotInstance(inst);
|
||||
|
||||
// Fresh row → in-memory defaults.
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
|
||||
|
||||
// Volume and play mode persist independently.
|
||||
botDb.saveVolume("bot-ps", 42);
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
|
||||
botDb.savePlayMode("bot-ps", "rloop");
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
|
||||
|
||||
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
|
||||
botDb.saveBotInstance({ ...inst, autoStart: true });
|
||||
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
|
||||
});
|
||||
|
||||
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
|
||||
// No row → defaults.
|
||||
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
|
||||
|
||||
botDb.saveBotInstance({
|
||||
id: "bot-v",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
});
|
||||
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
|
||||
botDb.saveVolume("bot-v", 250);
|
||||
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
|
||||
botDb.saveVolume("bot-v", -10);
|
||||
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
|
||||
botDb.savePlayMode("bot-v", "bogus");
|
||||
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
|
||||
});
|
||||
|
||||
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
|
||||
const p = join(dir, "legacy.db");
|
||||
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
|
||||
const legacy = createDatabase(p);
|
||||
legacy.db.exec("DROP TABLE bot_instances");
|
||||
legacy.db.exec(`CREATE TABLE bot_instances (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
|
||||
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
|
||||
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
|
||||
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
|
||||
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
|
||||
)`);
|
||||
legacy.db
|
||||
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
|
||||
.run("legacy-bot");
|
||||
legacy.close();
|
||||
|
||||
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
|
||||
const reopened = createDatabase(p);
|
||||
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
|
||||
expect(cols).toContain("volume");
|
||||
expect(cols).toContain("play_mode");
|
||||
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
|
||||
reopened.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists and clears customAvatarPath on a bot instance", () => {
|
||||
const inst = {
|
||||
id: "bot-1",
|
||||
name: "B",
|
||||
serverAddress: "x",
|
||||
serverPort: 9987,
|
||||
nickname: "n",
|
||||
defaultChannel: "",
|
||||
channelId: "",
|
||||
channelPassword: "",
|
||||
autoStart: false,
|
||||
serverProtocol: "",
|
||||
ts6ApiKey: "",
|
||||
serverPassword: "",
|
||||
};
|
||||
botDb.saveBotInstance(inst);
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
|
||||
botDb.setCustomAvatarPath("bot-1", null);
|
||||
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", () => {
|
||||
it("creates exactly one reserved guest row, idempotently", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-db-"));
|
||||
const p = join(dir, "t.db");
|
||||
const a = createDatabase(p); a.db.close();
|
||||
const b = createDatabase(p); // run again — must not duplicate
|
||||
const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined;
|
||||
expect(row?.role).toBe("guest");
|
||||
const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n;
|
||||
expect(n).toBe(1);
|
||||
b.db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("guest row does not break first-run detection (countUsers excludes it)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-"));
|
||||
const p = join(dir, "t.db");
|
||||
const d = createDatabase(p);
|
||||
const users = createUserStore(d.db);
|
||||
expect(users.countUsers()).toBe(0); // guest excluded → still needs setup
|
||||
d.db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
+580
-9
@@ -1,4 +1,46 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { CAPABILITIES, BOTS_ALL } from "./permissions.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 {
|
||||
botId: string;
|
||||
@@ -6,8 +48,9 @@ export interface PlayHistoryEntry {
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq" | "bilibili";
|
||||
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
|
||||
coverUrl: string;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface PlayHistoryRecord extends PlayHistoryEntry {
|
||||
@@ -22,11 +65,67 @@ export interface BotInstance {
|
||||
serverPort: number;
|
||||
nickname: string;
|
||||
defaultChannel: string;
|
||||
channelId: string;
|
||||
channelPassword: string;
|
||||
autoStart: boolean;
|
||||
/** "ts3" | "ts6" | "" (empty = auto-detect) */
|
||||
serverProtocol: string;
|
||||
/** API key for TS6 HTTP Query */
|
||||
ts6ApiKey: string;
|
||||
/** Password to join the TS server (server password) */
|
||||
serverPassword: string;
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
export interface ProfileConfig {
|
||||
avatarEnabled: boolean;
|
||||
descriptionEnabled: boolean;
|
||||
nicknameEnabled: boolean;
|
||||
awayStatusEnabled: boolean;
|
||||
channelDescEnabled: boolean;
|
||||
nowPlayingMsgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
||||
avatarEnabled: true,
|
||||
descriptionEnabled: true,
|
||||
nicknameEnabled: true,
|
||||
awayStatusEnabled: true,
|
||||
channelDescEnabled: 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 {
|
||||
id: number;
|
||||
userId: string;
|
||||
platform: string;
|
||||
playlistId: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BotDatabase {
|
||||
db: Database.Database;
|
||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||
@@ -34,16 +133,85 @@ export interface BotDatabase {
|
||||
saveBotInstance(instance: BotInstance): void;
|
||||
getBotInstances(): BotInstance[];
|
||||
deleteBotInstance(id: string): boolean;
|
||||
getProfileConfig(botId: string): ProfileConfig;
|
||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||
getPlayerSettings(botId: string): PlayerSettings;
|
||||
saveVolume(botId: string, volume: number): void;
|
||||
savePlayMode(botId: string, playMode: string): void;
|
||||
getCustomAvatarPath(botId: string): string | null;
|
||||
setCustomAvatarPath(botId: string, path: string | null): void;
|
||||
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
|
||||
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||
getFavorites(userId: string): FavoritePlaylist[];
|
||||
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;
|
||||
}
|
||||
|
||||
function migrateSchema(db: Database.Database): void {
|
||||
// Add identity column if it doesn't exist (migration for existing databases)
|
||||
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
|
||||
const hasIdentity = columns.some((c) => c.name === "identity");
|
||||
if (!hasIdentity) {
|
||||
const names = columns.map((c) => c.name);
|
||||
if (!names.includes("identity")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
|
||||
}
|
||||
if (!names.includes("serverProtocol")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!names.includes("ts6ApiKey")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!names.includes("serverPassword")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!names.includes("channelId")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN channelId TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
// Profile feature flags
|
||||
const profileCols = [
|
||||
"profile_avatar_enabled",
|
||||
"profile_description_enabled",
|
||||
"profile_nickname_enabled",
|
||||
"profile_away_enabled",
|
||||
"profile_channel_desc_enabled",
|
||||
"profile_now_playing_enabled",
|
||||
];
|
||||
for (const col of profileCols) {
|
||||
if (!names.includes(col)) {
|
||||
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||
}
|
||||
}
|
||||
if (!names.includes("custom_avatar_path")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
|
||||
}
|
||||
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
|
||||
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
|
||||
// exactly as before until the user changes them.
|
||||
if (!names.includes("volume")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
|
||||
}
|
||||
if (!names.includes("play_mode")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
|
||||
}
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
|
||||
const userColNames = userColumns.map((c) => c.name);
|
||||
if (!userColNames.includes("role")) {
|
||||
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 {
|
||||
@@ -57,6 +225,7 @@ function initTables(db: Database.Database): void {
|
||||
album TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
coverUrl TEXT NOT NULL,
|
||||
requestedBy TEXT NOT NULL DEFAULT '',
|
||||
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -67,22 +236,150 @@ function initTables(db: Database.Database): void {
|
||||
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 '',
|
||||
volume INTEGER NOT NULL DEFAULT 75,
|
||||
play_mode TEXT NOT NULL DEFAULT 'seq',
|
||||
identity TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
passwordHash TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
updatedAt INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
lastSeenAt INTEGER NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
actorId TEXT,
|
||||
actorUsername TEXT,
|
||||
targetUserId TEXT,
|
||||
targetUsername TEXT,
|
||||
action TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorite_playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
userId TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
playlistId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
coverUrl TEXT NOT NULL DEFAULT '',
|
||||
songCount INTEGER NOT NULL DEFAULT 0,
|
||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(userId, platform, playlistId)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
userId TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, permission),
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||
userId TEXT NOT NULL,
|
||||
botId TEXT NOT NULL,
|
||||
PRIMARY KEY (userId, botId),
|
||||
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 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'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time backfill: existing `member` users created before the
|
||||
* account-permissions feature are granted full access (all 5 capabilities +
|
||||
* the `bots.all` marker), exactly once per database. Admins are skipped (they
|
||||
* bypass permission checks). New members created after this runs are not
|
||||
* affected — they get the basic tier via POST /api/users. A marker row in
|
||||
* `schema_meta` makes this idempotent.
|
||||
*/
|
||||
export function backfillMemberPermissions(db: Database.Database): void {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
|
||||
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
|
||||
if (done) return;
|
||||
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
|
||||
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||
const tokens = [...CAPABILITIES, BOTS_ALL];
|
||||
const tx = db.transaction(() => {
|
||||
for (const m of members) {
|
||||
for (const t of tokens) insCap.run(m.id, t);
|
||||
}
|
||||
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the reserved guest principal exists. Idempotent via the PK on
|
||||
* `users.id`. This row only backs login-less guest sessions; it is excluded
|
||||
* from countUsers()/listUsers() so it never interferes with first-run setup
|
||||
* or the user-management UI, and holds an unusable password hash.
|
||||
*/
|
||||
export function ensureGuestUser(db: Database.Database): void {
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
|
||||
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
|
||||
}
|
||||
|
||||
export function createDatabase(dbPath: string): BotDatabase {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initTables(db);
|
||||
migrateSchema(db);
|
||||
backfillMemberPermissions(db);
|
||||
ensureGuestUser(db);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||
VALUES (@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, @requestedBy)
|
||||
`);
|
||||
|
||||
const selectHistory = db.prepare(`
|
||||
@@ -90,16 +387,20 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
`);
|
||||
|
||||
const upsertInstance = db.prepare(`
|
||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
|
||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
|
||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
|
||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelId, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
serverAddress = excluded.serverAddress,
|
||||
serverPort = excluded.serverPort,
|
||||
nickname = excluded.nickname,
|
||||
defaultChannel = excluded.defaultChannel,
|
||||
channelId = excluded.channelId,
|
||||
channelPassword = excluded.channelPassword,
|
||||
autoStart = excluded.autoStart,
|
||||
serverProtocol = excluded.serverProtocol,
|
||||
ts6ApiKey = excluded.ts6ApiKey,
|
||||
serverPassword = excluded.serverPassword,
|
||||
identity = excluded.identity
|
||||
`);
|
||||
|
||||
@@ -107,11 +408,117 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
|
||||
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
||||
|
||||
const selectProfileConfig = db.prepare(`
|
||||
SELECT profile_avatar_enabled, profile_description_enabled,
|
||||
profile_nickname_enabled, profile_away_enabled,
|
||||
profile_channel_desc_enabled, profile_now_playing_enabled
|
||||
FROM bot_instances WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateProfileConfig = db.prepare(`
|
||||
UPDATE bot_instances SET
|
||||
profile_avatar_enabled = @avatar,
|
||||
profile_description_enabled = @description,
|
||||
profile_nickname_enabled = @nickname,
|
||||
profile_away_enabled = @away,
|
||||
profile_channel_desc_enabled = @channelDesc,
|
||||
profile_now_playing_enabled = @nowPlaying
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
const selectPlayerSettings = db.prepare(
|
||||
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
|
||||
);
|
||||
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
|
||||
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
|
||||
|
||||
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
||||
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
||||
|
||||
const insertFavorite = db.prepare(`
|
||||
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
|
||||
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
|
||||
`);
|
||||
|
||||
const deleteFavorite = db.prepare(`
|
||||
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||
`);
|
||||
|
||||
const selectFavorites = db.prepare(`
|
||||
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
|
||||
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
|
||||
`);
|
||||
|
||||
const checkFavorited = db.prepare(`
|
||||
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 {
|
||||
db,
|
||||
|
||||
addPlayHistory(record) {
|
||||
insertHistory.run(record);
|
||||
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
|
||||
},
|
||||
|
||||
getPlayHistory(botId, limit) {
|
||||
@@ -133,6 +540,10 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
autoStart: r.autoStart === 1,
|
||||
serverProtocol: r.serverProtocol ?? "",
|
||||
ts6ApiKey: r.ts6ApiKey ?? "",
|
||||
serverPassword: r.serverPassword ?? "",
|
||||
channelId: r.channelId ?? "",
|
||||
identity: r.identity ?? undefined,
|
||||
}));
|
||||
},
|
||||
@@ -142,6 +553,166 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
getProfileConfig(botId) {
|
||||
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
|
||||
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
|
||||
return {
|
||||
avatarEnabled: row.profile_avatar_enabled === 1,
|
||||
descriptionEnabled: row.profile_description_enabled === 1,
|
||||
nicknameEnabled: row.profile_nickname_enabled === 1,
|
||||
awayStatusEnabled: row.profile_away_enabled === 1,
|
||||
channelDescEnabled: row.profile_channel_desc_enabled === 1,
|
||||
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
|
||||
};
|
||||
},
|
||||
|
||||
saveProfileConfig(botId, config) {
|
||||
updateProfileConfig.run({
|
||||
id: botId,
|
||||
avatar: config.avatarEnabled ? 1 : 0,
|
||||
description: config.descriptionEnabled ? 1 : 0,
|
||||
nickname: config.nicknameEnabled ? 1 : 0,
|
||||
away: config.awayStatusEnabled ? 1 : 0,
|
||||
channelDesc: config.channelDescEnabled ? 1 : 0,
|
||||
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
|
||||
});
|
||||
},
|
||||
|
||||
getPlayerSettings(botId) {
|
||||
const row = selectPlayerSettings.get(botId) as
|
||||
| { volume: number | null; play_mode: string | null }
|
||||
| undefined;
|
||||
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
|
||||
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
|
||||
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
|
||||
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
|
||||
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
|
||||
const volume = Number.isFinite(rawVol)
|
||||
? Math.max(0, Math.min(100, Math.round(rawVol)))
|
||||
: DEFAULT_PLAYER_SETTINGS.volume;
|
||||
const playMode =
|
||||
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
|
||||
? row.play_mode
|
||||
: DEFAULT_PLAYER_SETTINGS.playMode;
|
||||
return { volume, playMode };
|
||||
},
|
||||
|
||||
saveVolume(botId, volume) {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
|
||||
updateVolume.run(clamped, botId);
|
||||
},
|
||||
|
||||
savePlayMode(botId, playMode) {
|
||||
// Persist only recognized modes so a bad value can never poison the row.
|
||||
if (!PLAY_MODES.has(playMode)) return;
|
||||
updatePlayMode.run(playMode, botId);
|
||||
},
|
||||
|
||||
getCustomAvatarPath(botId) {
|
||||
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
|
||||
return row?.custom_avatar_path ?? null;
|
||||
},
|
||||
setCustomAvatarPath(botId, path) {
|
||||
updateCustomAvatar.run(path, botId);
|
||||
},
|
||||
|
||||
addFavorite(userId, playlist) {
|
||||
insertFavorite.run({ userId, ...playlist });
|
||||
},
|
||||
|
||||
removeFavorite(userId, playlistId, platform) {
|
||||
const result = deleteFavorite.run(userId, playlistId, platform);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
getFavorites(userId) {
|
||||
return selectFavorites.all(userId) as FavoritePlaylist[];
|
||||
},
|
||||
|
||||
isFavorited(userId, playlistId, platform) {
|
||||
const row = checkFavorited.get(userId, playlistId, platform);
|
||||
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() {
|
||||
db.close();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
|
||||
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
|
||||
|
||||
describe("backfillMemberPermissions", () => {
|
||||
let dbFile: string;
|
||||
let db: BotDatabase;
|
||||
function fresh() {
|
||||
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
db = createDatabase(dbFile);
|
||||
}
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
for (const s of ["", "-wal", "-shm"]) {
|
||||
try {
|
||||
fs.rmSync(dbFile + s, { force: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it("grants existing members full access + bots.all, skips admins, once", () => {
|
||||
fresh();
|
||||
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
|
||||
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
|
||||
const now = Date.now();
|
||||
const ins = db.db.prepare(
|
||||
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
|
||||
);
|
||||
ins.run("m1", "mem", "x", now, now, "member");
|
||||
ins.run("a1", "adm", "x", now, now, "admin");
|
||||
|
||||
backfillMemberPermissions(db.db);
|
||||
|
||||
const store = createPermissionStore(db.db);
|
||||
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
|
||||
expect(store.getBotAccess("m1")).toBe("all");
|
||||
expect(store.getCapabilities("a1")).toEqual([]);
|
||||
expect(store.getBotAccess("a1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent — running again does not change or re-grant", () => {
|
||||
fresh();
|
||||
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
|
||||
const now = Date.now();
|
||||
db.db
|
||||
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
|
||||
.run("m1", "mem", "x", now, now, "member");
|
||||
backfillMemberPermissions(db.db);
|
||||
// member restricted afterwards
|
||||
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
|
||||
// second run must NOT re-grant (marker present)
|
||||
backfillMemberPermissions(db.db);
|
||||
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createPermissionStore } from "./permissions.js";
|
||||
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
|
||||
|
||||
describe("PermissionStore", () => {
|
||||
let dbFile: string;
|
||||
let db: BotDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
db = createDatabase(dbFile);
|
||||
db.db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
|
||||
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
try { fs.rmSync(dbFile, { force: true }); } catch {}
|
||||
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
|
||||
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
|
||||
});
|
||||
|
||||
it("exposes the five capability tokens and a basic tier", () => {
|
||||
expect(CAPABILITIES).toEqual([
|
||||
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
|
||||
]);
|
||||
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
|
||||
});
|
||||
|
||||
it("defaults to no capabilities and no bots", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
expect(store.getCapabilities("u1")).toEqual([]);
|
||||
expect(store.getBotAccess("u1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips capabilities and a specific bot list", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
|
||||
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
|
||||
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
|
||||
});
|
||||
|
||||
it("stores the all-bots flag as 'all'", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
|
||||
expect(store.getBotAccess("u1")).toBe("all");
|
||||
});
|
||||
|
||||
it("setPermissions replaces prior capabilities and bots", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
|
||||
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
|
||||
expect(store.getCapabilities("u1")).toEqual(["quality"]);
|
||||
expect(store.getBotAccess("u1")).toBe("all");
|
||||
});
|
||||
|
||||
it("ignores unknown capability tokens", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
|
||||
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
|
||||
});
|
||||
|
||||
it("pruneBot removes a bot from every user's allow-list", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
|
||||
store.pruneBot("botA");
|
||||
expect(store.getBotAccess("u1")).toEqual(["botB"]);
|
||||
});
|
||||
|
||||
describe("resolvePermissionContext", () => {
|
||||
it("admin gets all capabilities and all bots regardless of stored rows", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
const ctx = resolvePermissionContext("admin", "u1", store);
|
||||
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
|
||||
expect(ctx.bots).toBe("all");
|
||||
});
|
||||
it("member reflects stored capabilities + bot access", () => {
|
||||
const store = createPermissionStore(db.db);
|
||||
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
|
||||
const ctx = resolvePermissionContext("member", "u1", store);
|
||||
expect([...ctx.capabilities]).toEqual(["player.control"]);
|
||||
expect(ctx.bots).toEqual(new Set(["b1"]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
|
||||
|
||||
describe("resolvePermissionContext guest branch", () => {
|
||||
const noStore = {
|
||||
getCapabilities: () => [],
|
||||
getBotAccess: () => [] as string[],
|
||||
setPermissions: () => {},
|
||||
pruneBot: () => {},
|
||||
};
|
||||
|
||||
it("guest has no member capabilities and exposes the guest permissions + bots", () => {
|
||||
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
|
||||
bots: ["bot1"],
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: true, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
});
|
||||
expect([...ctx.capabilities]).toEqual([]);
|
||||
expect(ctx.bots).toBeInstanceOf(Set);
|
||||
expect((ctx.bots as Set<string>).has("bot1")).toBe(true);
|
||||
expect(ctx.guest?.addToQueue).toBe(true);
|
||||
expect(ctx.guest?.skip).toBe(true);
|
||||
});
|
||||
|
||||
it("guest with bots:'all' resolves to 'all'", () => {
|
||||
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
});
|
||||
expect(ctx.bots).toBe("all");
|
||||
});
|
||||
|
||||
it("exposes the 8 canonical flags", () => {
|
||||
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
|
||||
["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const CAPABILITIES = [
|
||||
"player.control",
|
||||
"player.queue",
|
||||
"bot.manage",
|
||||
"platform.auth",
|
||||
"quality",
|
||||
] as const;
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
|
||||
export const BOTS_ALL = "bots.all";
|
||||
|
||||
/** Capabilities granted to a newly-created member by default. */
|
||||
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
|
||||
|
||||
export function isCapability(x: string): x is Capability {
|
||||
return (CAPABILITIES as readonly string[]).includes(x);
|
||||
}
|
||||
|
||||
export type BotAccess = "all" | string[];
|
||||
|
||||
export interface GuestPermissions {
|
||||
addToQueue: boolean;
|
||||
playNext: boolean;
|
||||
playNow: boolean;
|
||||
skip: boolean;
|
||||
transport: boolean;
|
||||
removeClear: boolean;
|
||||
playMode: boolean;
|
||||
/** Load + play an entire playlist/album (clears the queue). Issue #103. */
|
||||
playCollection: boolean;
|
||||
}
|
||||
|
||||
export const GUEST_PERMISSION_FLAGS = [
|
||||
"addToQueue",
|
||||
"playNext",
|
||||
"playNow",
|
||||
"skip",
|
||||
"transport",
|
||||
"removeClear",
|
||||
"playMode",
|
||||
"playCollection",
|
||||
] as const;
|
||||
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
|
||||
|
||||
export interface PermissionStore {
|
||||
getCapabilities(userId: string): Capability[];
|
||||
getBotAccess(userId: string): BotAccess;
|
||||
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
|
||||
pruneBot(botId: string): void;
|
||||
}
|
||||
|
||||
export function createPermissionStore(db: Database.Database): PermissionStore {
|
||||
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
|
||||
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
|
||||
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
|
||||
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
|
||||
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
|
||||
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
|
||||
|
||||
return {
|
||||
getCapabilities(userId) {
|
||||
return (selCaps.all(userId) as { permission: string }[])
|
||||
.map((r) => r.permission)
|
||||
.filter((p): p is Capability => isCapability(p));
|
||||
},
|
||||
getBotAccess(userId) {
|
||||
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
|
||||
if (all) return "all";
|
||||
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
|
||||
},
|
||||
setPermissions(userId, input) {
|
||||
const caps = input.capabilities.filter(isCapability);
|
||||
const tx = db.transaction(() => {
|
||||
delCaps.run(userId);
|
||||
delBots.run(userId);
|
||||
for (const c of caps) insCap.run(userId, c);
|
||||
if (input.bots === "all") {
|
||||
insCap.run(userId, BOTS_ALL);
|
||||
} else {
|
||||
for (const b of input.bots) insBot.run(userId, b);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
},
|
||||
pruneBot(botId) {
|
||||
pruneBotStmt.run(botId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermissionContext {
|
||||
capabilities: Set<string>;
|
||||
bots: "all" | Set<string>;
|
||||
guest?: GuestPermissions;
|
||||
}
|
||||
|
||||
export function resolvePermissionContext(
|
||||
role: "admin" | "member" | "guest",
|
||||
userId: string,
|
||||
store: PermissionStore,
|
||||
guest?: { bots: BotAccess; permissions: GuestPermissions }
|
||||
): PermissionContext {
|
||||
if (role === "admin") {
|
||||
return { capabilities: new Set(CAPABILITIES), bots: "all" };
|
||||
}
|
||||
if (role === "guest") {
|
||||
const bots = guest?.bots ?? [];
|
||||
return {
|
||||
capabilities: new Set<string>(),
|
||||
bots: bots === "all" ? "all" : new Set(bots),
|
||||
guest: guest?.permissions,
|
||||
};
|
||||
}
|
||||
const access = store.getBotAccess(userId);
|
||||
return {
|
||||
capabilities: new Set(store.getCapabilities(userId)),
|
||||
bots: access === "all" ? "all" : new Set(access),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, type UserStore } from "./users.js";
|
||||
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
|
||||
|
||||
function sha256(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
describe("SessionStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let sessions: SessionStore;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
userId = u.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
|
||||
expect(row.id).toBe(sha256(token));
|
||||
expect(row.id).not.toBe(token);
|
||||
});
|
||||
|
||||
it("validateAndTouch returns the user for a fresh token", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe(userId);
|
||||
expect(result!.username).toBe("alice");
|
||||
expect(result!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("validateAndTouch returns null and deletes the row for an expired session", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token } = sessions.createSession(userId);
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token } = sessions.createSession(userId);
|
||||
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
|
||||
sessions.validateAndTouch(token);
|
||||
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
|
||||
expect(after.lastSeenAt).toBe(before.lastSeenAt);
|
||||
});
|
||||
|
||||
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
|
||||
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
|
||||
sessions.validateAndTouch(token);
|
||||
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
|
||||
expect(row.lastSeenAt).toBe(Date.now());
|
||||
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
|
||||
});
|
||||
|
||||
it("deleteSession removes the row", () => {
|
||||
const { token } = sessions.createSession(userId);
|
||||
sessions.deleteSession(token);
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(0);
|
||||
expect(sessions.validateAndTouch(token)).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteAllForUser keeps the exceptToken session", () => {
|
||||
const a = sessions.createSession(userId);
|
||||
const b = sessions.createSession(userId);
|
||||
sessions.deleteAllForUser(userId, a.token);
|
||||
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
|
||||
expect(sessions.validateAndTouch(b.token)).toBeNull();
|
||||
});
|
||||
|
||||
it("cleanupExpired removes only expired rows", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
sessions.createSession(userId); // expires later
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
|
||||
sessions.createSession(userId); // fresh
|
||||
sessions.cleanupExpired();
|
||||
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(remaining).toBe(1);
|
||||
});
|
||||
|
||||
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
|
||||
// Create MAX + 2 sessions for the same user.
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
|
||||
tokens.push(sessions.createSession(userId).token);
|
||||
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
|
||||
}
|
||||
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||
// The first two should have been evicted, the last MAX remain
|
||||
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
|
||||
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
|
||||
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
|
||||
});
|
||||
|
||||
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
|
||||
// better-sqlite3 transactions are serialised at the engine level. Calling
|
||||
// createSession N times sequentially via Promise.all proves atomic check+insert.
|
||||
const N = MAX_SESSIONS_PER_USER + 3;
|
||||
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
|
||||
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
|
||||
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest sessions", () => {
|
||||
let botDb: BotDatabase;
|
||||
let sessions: SessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
sessions = createSessionStore(botDb.db);
|
||||
// Create the synthetic guest user row to satisfy the sessions FK.
|
||||
botDb.db
|
||||
.prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
|
||||
.run(Date.now(), Date.now());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
|
||||
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
|
||||
}
|
||||
// The first token must STILL validate (not evicted).
|
||||
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
|
||||
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
|
||||
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
|
||||
});
|
||||
|
||||
it("ttlMs sets a shorter expiry than the default", () => {
|
||||
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
|
||||
});
|
||||
|
||||
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
|
||||
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
|
||||
// Force the touch branch: backdate lastSeenAt past the touch interval.
|
||||
botDb.db
|
||||
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
|
||||
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result?.role).toBe("guest");
|
||||
const row = botDb.db
|
||||
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
|
||||
.get() as { expiresAt: number };
|
||||
// Should refresh to ~now + 1 day, NOT now + 7 days.
|
||||
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
|
||||
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
|
||||
// Sanity: well below the 7d window.
|
||||
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
|
||||
});
|
||||
|
||||
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
|
||||
botDb.db
|
||||
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
|
||||
.run(Date.now(), Date.now());
|
||||
const { token } = sessions.createSession("admin1");
|
||||
botDb.db
|
||||
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
|
||||
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result?.role).toBe("admin");
|
||||
const row = botDb.db
|
||||
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
|
||||
.get() as { expiresAt: number };
|
||||
// Refreshes to ~now + 7 days, NOT the 1d guest window.
|
||||
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
|
||||
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
|
||||
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface SessionValidation {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: "admin" | "member" | "guest";
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
|
||||
validateAndTouch(rawToken: string): SessionValidation | null;
|
||||
deleteSession(rawToken: string): void;
|
||||
deleteAllForUser(userId: string, exceptToken?: string): void;
|
||||
cleanupExpired(): void;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createSessionStore(db: Database.Database): SessionStore {
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const selectStmt = db.prepare(`
|
||||
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
|
||||
FROM sessions s INNER JOIN users u ON u.id = s.userId
|
||||
WHERE s.id = ?
|
||||
`);
|
||||
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
|
||||
const touchStmt = db.prepare(
|
||||
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
|
||||
);
|
||||
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
|
||||
const deleteAllForUserExceptStmt = db.prepare(
|
||||
"DELETE FROM sessions WHERE userId = ? AND id != ?"
|
||||
);
|
||||
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
|
||||
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
|
||||
const deleteOldestForUserStmt = db.prepare(
|
||||
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
|
||||
);
|
||||
|
||||
return {
|
||||
createSession(userId, opts) {
|
||||
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
||||
// Wrap the count → delete → insert in a transaction so concurrent logins
|
||||
// for the same user can't both pass the cap check and both insert,
|
||||
// ending up 1 over cap (race window between count and insert).
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const id = hashToken(token);
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
|
||||
const tx = db.transaction(() => {
|
||||
if (!opts?.skipCap) {
|
||||
const existing = (countForUserStmt.get(userId) as { n: number }).n;
|
||||
if (existing >= MAX_SESSIONS_PER_USER) {
|
||||
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
|
||||
}
|
||||
}
|
||||
insertStmt.run(id, userId, now, expiresAt, now);
|
||||
});
|
||||
tx();
|
||||
return { token, expiresAt };
|
||||
},
|
||||
|
||||
validateAndTouch(rawToken) {
|
||||
if (!rawToken) return null;
|
||||
const id = hashToken(rawToken);
|
||||
const row = selectStmt.get(id) as
|
||||
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const now = Date.now();
|
||||
if (row.expiresAt < now) {
|
||||
deleteByIdStmt.run(id);
|
||||
return null;
|
||||
}
|
||||
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
|
||||
// Refresh against the role's own TTL — guests are short-lived (1d) and
|
||||
// must NOT be bumped to the member/admin 7d window on touch.
|
||||
const ttl = row.role === "guest" ? GUEST_SESSION_TTL_MS : SESSION_TTL_MS;
|
||||
touchStmt.run(now, now + ttl, id);
|
||||
}
|
||||
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
|
||||
},
|
||||
|
||||
deleteSession(rawToken) {
|
||||
deleteByIdStmt.run(hashToken(rawToken));
|
||||
},
|
||||
|
||||
deleteAllForUser(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
|
||||
} else {
|
||||
deleteAllForUserStmt.run(userId);
|
||||
}
|
||||
},
|
||||
|
||||
cleanupExpired() {
|
||||
cleanupStmt.run(Date.now());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
|
||||
|
||||
describe("UserStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("countUsers is 0 on a fresh db", () => {
|
||||
expect(users.countUsers()).toBe(0);
|
||||
});
|
||||
|
||||
it("createUser stores the user and bumps countUsers", async () => {
|
||||
const u = await users.createUser("alice", "pw-hunter2", "member");
|
||||
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(u.username).toBe("alice");
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("findByUsername is case-insensitive and returns null for missing", async () => {
|
||||
await users.createUser("Alice", "pw-alice", "member");
|
||||
expect(users.findByUsername("ALICE")).not.toBeNull();
|
||||
expect(users.findByUsername("alice")).not.toBeNull();
|
||||
expect(users.findByUsername("bob")).toBeNull();
|
||||
});
|
||||
|
||||
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
|
||||
await users.createUser("Alice", "pw-alice", "member");
|
||||
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
|
||||
});
|
||||
|
||||
it("verifyPassword accepts correct password and rejects wrong one", async () => {
|
||||
await users.createUser("alice", "correct-horse-battery-staple", "member");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(row).not.toBeNull();
|
||||
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
|
||||
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
|
||||
});
|
||||
|
||||
it("changePassword updates the hash so the old password no longer verifies", async () => {
|
||||
const u = await users.createUser("alice", "old-pw-pw", "member");
|
||||
await users.changePassword(u.id, "new-pw-pw");
|
||||
const row = users.findByUsername("alice");
|
||||
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
|
||||
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
|
||||
await users.createUser("alice", "pw-alice", "member");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
const list = users.listUsers();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].username).toBe("alice");
|
||||
expect(list[1].username).toBe("bob");
|
||||
expect(list[0]).not.toHaveProperty("passwordHash");
|
||||
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(typeof list[0].createdAt).toBe("number");
|
||||
});
|
||||
|
||||
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
|
||||
const u = await users.createUser("alice", "pw-alice", "member");
|
||||
expect(users.deleteUser(u.id)).toBe(true);
|
||||
expect(users.countUsers()).toBe(0);
|
||||
expect(users.deleteUser("not-a-real-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
|
||||
const a = await users.createFirstUser("alice", "pw-alice");
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.username).toBe("alice");
|
||||
const b = await users.createFirstUser("bob", "pw-bob-bob");
|
||||
expect(b).toBeNull();
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
|
||||
const [a, b, c] = await Promise.all([
|
||||
users.createFirstUser("alice", "pw-alice"),
|
||||
users.createFirstUser("bob", "pw-bob-bob"),
|
||||
users.createFirstUser("charlie", "pw-charlie-pw"),
|
||||
]);
|
||||
const created = [a, b, c].filter((u) => u !== null);
|
||||
expect(created).toHaveLength(1);
|
||||
expect(users.countUsers()).toBe(1);
|
||||
});
|
||||
|
||||
it("createFirstUser always creates an admin", async () => {
|
||||
const u = await users.createFirstUser("alice", "pw-alice");
|
||||
expect(u).not.toBeNull();
|
||||
expect(u!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("countAdmins reflects only role=admin", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
expect(users.countUsers()).toBe(2);
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
|
||||
it("setRole changes the role and returns true; false for unknown id", async () => {
|
||||
const u = await users.createUser("alice", "pw-alice", "member");
|
||||
expect(users.setRole(u.id, "admin")).toBe(true);
|
||||
expect(users.findById(u.id)!.role).toBe("admin");
|
||||
expect(users.setRole("nope", "admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("listUsers includes role", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "member");
|
||||
const list = users.listUsers();
|
||||
const alice = list.find((u) => u.username === "alice")!;
|
||||
const bob = list.find((u) => u.username === "bob")!;
|
||||
expect(alice.role).toBe("admin");
|
||||
expect(bob.role).toBe("member");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
|
||||
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
|
||||
expect(users.findById(alice.id)!.role).toBe("member");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
|
||||
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
// Concurrent demotion of both
|
||||
const [r1, r2] = await Promise.all([
|
||||
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
|
||||
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
|
||||
]);
|
||||
// Exactly one should succeed; the other gets "would_orphan"
|
||||
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||
expect(oks).toBe(1);
|
||||
expect(orphans).toBe(1);
|
||||
// System retains at least one admin
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
|
||||
expect(users.findById(alice.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
|
||||
await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
||||
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
|
||||
expect(users.findById(bob.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
|
||||
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
|
||||
const [r1, r2] = await Promise.all([
|
||||
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
|
||||
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
|
||||
]);
|
||||
const oks = [r1, r2].filter((r) => r === "ok").length;
|
||||
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
|
||||
expect(oks).toBe(1);
|
||||
expect(orphans).toBe(1);
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest row exclusion", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("countUsers and listUsers ignore the reserved guest row", async () => {
|
||||
await users.createUser("alice", "password123", "member");
|
||||
// Insert the reserved guest row directly (mirrors the migration).
|
||||
botDb.db.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
|
||||
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
|
||||
|
||||
expect(users.countUsers()).toBe(1); // alice only
|
||||
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin refuses to re-role the reserved guest principal", () => {
|
||||
// The guest row is seeded by createDatabase via ensureGuestUser.
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest"); // sanity
|
||||
expect(users.setRoleIfNotLastAdmin(GUEST_USER_ID, "admin")).toBe("not_found");
|
||||
// The guest row's role is unchanged.
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin refuses to delete the reserved guest principal", () => {
|
||||
expect(users.findById(GUEST_USER_ID)).not.toBeNull(); // sanity
|
||||
expect(users.deleteUserIfNotLastAdmin(GUEST_USER_ID)).toBe("not_found");
|
||||
// The guest row still exists.
|
||||
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
export type UserRole = "admin" | "member" | "guest";
|
||||
|
||||
/** Reserved synthetic principal for login-less guest sessions. The username is
|
||||
* non-ASCII so it can never collide with an API-created account (which is
|
||||
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
|
||||
export const GUEST_USER_ID = "__guest__";
|
||||
export const GUEST_USERNAME = "游客";
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UserStore {
|
||||
countUsers(): number;
|
||||
countAdmins(): number;
|
||||
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
|
||||
createFirstUser(username: string, password: string): Promise<UserRow | null>;
|
||||
findByUsername(username: string): UserRow | null;
|
||||
findById(id: string): UserRow | null;
|
||||
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
||||
changePassword(userId: string, newPassword: string): Promise<void>;
|
||||
setRole(userId: string, role: UserRole): boolean;
|
||||
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
|
||||
deleteUser(id: string): boolean;
|
||||
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
|
||||
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||
}
|
||||
|
||||
export class UsernameTakenError extends Error {
|
||||
constructor(username: string) {
|
||||
super(`username taken: ${username}`);
|
||||
this.name = "UsernameTakenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createUserStore(db: Database.Database): UserStore {
|
||||
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
|
||||
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const findByUsernameStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
|
||||
);
|
||||
const findByIdStmt = db.prepare(
|
||||
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
|
||||
);
|
||||
const updatePasswordStmt = db.prepare(
|
||||
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
const updateRoleStmt = db.prepare(
|
||||
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
const listUsersStmt = db.prepare(
|
||||
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
|
||||
);
|
||||
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
||||
|
||||
return {
|
||||
countUsers() {
|
||||
return (countStmt.get() as { n: number }).n;
|
||||
},
|
||||
|
||||
countAdmins() {
|
||||
return (countAdminsStmt.get() as { n: number }).n;
|
||||
},
|
||||
|
||||
async createUser(username, password, role) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
try {
|
||||
insertStmt.run(id, username, hash, now, now, role);
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
throw new UsernameTakenError(username);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
|
||||
},
|
||||
|
||||
async createFirstUser(username, password) {
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
const run = db.transaction(() => {
|
||||
const count = (countStmt.get() as { n: number }).n;
|
||||
if (count !== 0) return null;
|
||||
try {
|
||||
insertStmt.run(id, username, hash, now, now, "admin");
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
|
||||
});
|
||||
return run();
|
||||
},
|
||||
|
||||
findByUsername(username) {
|
||||
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
|
||||
},
|
||||
|
||||
verifyPassword(plain, hash) {
|
||||
return bcrypt.compare(plain, hash);
|
||||
},
|
||||
|
||||
async changePassword(userId, newPassword) {
|
||||
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
updatePasswordStmt.run(hash, Date.now(), userId);
|
||||
},
|
||||
|
||||
setRole(userId, role) {
|
||||
const result = updateRoleStmt.run(role, Date.now(), userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
setRoleIfNotLastAdmin(id, newRole) {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
||||
if (row.role === newRole) return "ok" as const; // no-op
|
||||
if (row.role === "admin" && newRole === "member") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
if (adminCount <= 1) return "would_orphan" as const;
|
||||
}
|
||||
updateRoleStmt.run(newRole, Date.now(), id);
|
||||
return "ok" as const;
|
||||
});
|
||||
return tx();
|
||||
},
|
||||
|
||||
listUsers() {
|
||||
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
|
||||
},
|
||||
|
||||
deleteUser(id) {
|
||||
const result = deleteUserStmt.run(id);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
deleteUserIfNotLastAdmin(id) {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
||||
if (row.role === "admin") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
if (adminCount <= 1) return "would_orphan" as const;
|
||||
}
|
||||
deleteUserStmt.run(id);
|
||||
return "ok" as const;
|
||||
});
|
||||
return tx();
|
||||
},
|
||||
};
|
||||
}
|
||||
Regular → Executable
+98
-5
@@ -1,26 +1,43 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig, saveConfig } from "./data/config.js";
|
||||
import { loadConfig, saveConfig, migrateLegacyConfig, isProviderEnabled } from "./data/config.js";
|
||||
import { createDatabase } from "./data/database.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { createApiServerManager } from "./music/api-server.js";
|
||||
import { NeteaseProvider } from "./music/netease.js";
|
||||
import { QQMusicProvider } from "./music/qq.js";
|
||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||
import { LocalMusicProvider } from "./music/local.js";
|
||||
import { KugouProvider } from "./music/kugou.js";
|
||||
import { JellyfinProvider } from "./music/jellyfin.js";
|
||||
import { SpotifyProvider } from "./music/spotify/provider.js";
|
||||
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
|
||||
import { createCookieStore } from "./music/auth.js";
|
||||
import { createAvatarStore } from "./data/avatars.js";
|
||||
import { createPermissionStore } from "./data/permissions.js";
|
||||
import { BotManager } from "./bot/manager.js";
|
||||
import { createWebServer } from "./web/server.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const DATA_DIR = path.join(ROOT_DIR, "data");
|
||||
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
||||
// config.json lives under the persisted data dir (the Docker volume) alongside the
|
||||
// DB/cookies/logs, so it survives container restarts and manual edits take effect
|
||||
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
|
||||
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
|
||||
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
||||
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
||||
const LOG_DIR = path.join(DATA_DIR, "logs");
|
||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
|
||||
const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify");
|
||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||
|
||||
async function main() {
|
||||
// Migrate a pre-#86 root-level config.json into the data dir so existing
|
||||
// installs keep their settings; no-op if already migrated or none exists.
|
||||
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
|
||||
const config = loadConfig(CONFIG_PATH);
|
||||
saveConfig(CONFIG_PATH, config);
|
||||
|
||||
@@ -36,7 +53,14 @@ async function main() {
|
||||
const db = createDatabase(DB_PATH);
|
||||
|
||||
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
|
||||
);
|
||||
await apiServer.start();
|
||||
@@ -44,14 +68,64 @@ async function main() {
|
||||
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
const bilibiliProvider = new BiliBiliProvider();
|
||||
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
|
||||
const kugouProvider = new KugouProvider();
|
||||
const spotifyProvider = new SpotifyProvider();
|
||||
// Safety gate (spec §7): the source is inert unless EXPLICITLY enabled.
|
||||
// Only feed credentials when enabled — otherwise the provider has no creds,
|
||||
// hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false,
|
||||
// so setting a Client ID/Secret alone (enabled:false) never activates Spotify.
|
||||
if (config.spotify.enabled && config.spotify.clientId) {
|
||||
spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret);
|
||||
}
|
||||
|
||||
const cookieStore = createCookieStore(COOKIE_DIR);
|
||||
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||
const neteaseCookie = cookieStore.load("netease");
|
||||
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
|
||||
const qqCookie = cookieStore.load("qq");
|
||||
if (qqCookie) qqProvider.setCookie(qqCookie);
|
||||
const bilibiliCookie = cookieStore.load("bilibili");
|
||||
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
||||
const kugouCookie = cookieStore.load("kugou");
|
||||
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);
|
||||
|
||||
// Single process-wide Spotify authorization (one Premium account for Stage 3).
|
||||
// Threaded into BOTH the web OAuth router and every bot's SpotifyController so
|
||||
// a web login immediately authorizes playback (C3.1). Own-app clientId => the
|
||||
// redirect points at this bot's web callback; empty clientId leaves OAuth
|
||||
// disabled (isAuthorized() stays false and the Rust backend never starts).
|
||||
const spotifyOAuthClientId = config.spotify.clientId.trim();
|
||||
const spotifyOAuth = new SpotifyOAuth({
|
||||
clientId: spotifyOAuthClientId || undefined,
|
||||
redirectUri: spotifyOAuthClientId
|
||||
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
|
||||
: undefined,
|
||||
store: createFileOAuthTokenStore(
|
||||
path.join(SPOTIFY_DATA_DIR, "oauth", "oauth-tokens.json"),
|
||||
),
|
||||
});
|
||||
|
||||
const botManager = new BotManager(
|
||||
neteaseProvider,
|
||||
@@ -59,7 +133,16 @@ async function main() {
|
||||
bilibiliProvider,
|
||||
db,
|
||||
config,
|
||||
logger
|
||||
logger,
|
||||
avatarStore,
|
||||
permissions,
|
||||
CONFIG_PATH,
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
spotifyProvider,
|
||||
SPOTIFY_DATA_DIR,
|
||||
spotifyOAuth,
|
||||
jellyfinProvider
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
@@ -69,16 +152,26 @@ async function main() {
|
||||
neteaseProvider,
|
||||
qqProvider,
|
||||
bilibiliProvider,
|
||||
localProvider,
|
||||
kugouProvider,
|
||||
spotifyProvider,
|
||||
jellyfinProvider,
|
||||
database: db,
|
||||
avatarStore,
|
||||
config,
|
||||
configPath: CONFIG_PATH,
|
||||
logger,
|
||||
cookieStore,
|
||||
staticDir: STATIC_DIR,
|
||||
spotifyOAuth,
|
||||
});
|
||||
await webServer.start();
|
||||
|
||||
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
||||
logger.info(`WebUI: http://localhost:${config.webPort}`);
|
||||
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
|
||||
logger.info(
|
||||
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
|
||||
);
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info("Shutting down...");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
// Record every listen() the QQ sidecar makes so we can assert it is always
|
||||
// pinned to the configured port (regression coverage for issue #122).
|
||||
const mockState = vi.hoisted(() => ({
|
||||
listenCalls: [] as Array<{ port: number; host: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@sansenjian/qq-music-api", () => {
|
||||
const app = {
|
||||
listen(port: number, host: string, cb?: () => void) {
|
||||
mockState.listenCalls.push({ port, host });
|
||||
const server = {
|
||||
address: () => ({ port, address: host, family: "IPv4" as const }),
|
||||
on() {
|
||||
return server;
|
||||
},
|
||||
close(done?: () => void) {
|
||||
done?.();
|
||||
},
|
||||
};
|
||||
// Real net/Koa fire the listening callback on a later tick, after the
|
||||
// caller has captured the returned server handle.
|
||||
if (cb) setImmediate(cb);
|
||||
return server;
|
||||
},
|
||||
};
|
||||
return { default: app };
|
||||
});
|
||||
|
||||
describe("describeQqApiStartupError", () => {
|
||||
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
|
||||
const hint = describeQqApiStartupError({ code: "ERR_REQUIRE_ESM", message: "..." });
|
||||
expect(hint).toMatch(/ERR_REQUIRE_ESM/);
|
||||
expect(hint).toMatch(/~2\.4\.0/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("flags ERR_REQUIRE_ESM by message when the code is absent", () => {
|
||||
const hint = describeQqApiStartupError(
|
||||
new Error("require() of ES Module .../@sansenjian/qq-music-api/dist/index.js not supported")
|
||||
);
|
||||
expect(hint).toMatch(/incompatible @sansenjian\/qq-music-api/);
|
||||
});
|
||||
|
||||
it("flags a Node engine mismatch with a Node-upgrade hint", () => {
|
||||
const hint = describeQqApiStartupError(new Error("Unsupported engine: requires Node >=20.17"));
|
||||
expect(hint).toMatch(/Node >=20\.17/);
|
||||
expect(hint).toMatch(/~2\.2\.10/);
|
||||
});
|
||||
|
||||
it("returns null for an unrelated startup error (falls back to the generic warning)", () => {
|
||||
expect(describeQqApiStartupError(new Error("EADDRINUSE: port in use"))).toBeNull();
|
||||
expect(describeQqApiStartupError(undefined)).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;
|
||||
}
|
||||
});
|
||||
});
|
||||
+140
-29
@@ -5,6 +5,10 @@ import type { Server } from "node:http";
|
||||
export interface ApiServerOptions {
|
||||
neteasePort: 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 {
|
||||
@@ -14,10 +18,38 @@ export interface ApiServerManager {
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a QQ Music API (@sansenjian/qq-music-api) startup failure into
|
||||
* actionable operator guidance, or null when it isn't a recognised
|
||||
* dependency/runtime mismatch. Exported for testing.
|
||||
*
|
||||
* Background: the package became ESM in 2.3.x. A loose `^` range could pull an
|
||||
* ESM-only build (2.3.0/2.3.1) that throws ERR_REQUIRE_ESM, or a 2.4.x build
|
||||
* that needs Node >=20.17 — either way the embedded server never binds, so
|
||||
* every QQ request fails downstream with ECONNREFUSED on the API port.
|
||||
*/
|
||||
export function describeQqApiStartupError(err: unknown): string | null {
|
||||
const e = (err ?? {}) as { code?: string; message?: string };
|
||||
const code = String(e.code ?? "");
|
||||
const msg = String(e.message ?? "");
|
||||
if (code === "ERR_REQUIRE_ESM" || /ERR_REQUIRE_ESM|require\(\) of ES ?Module/i.test(msg)) {
|
||||
return (
|
||||
"an incompatible @sansenjian/qq-music-api build is installed (ERR_REQUIRE_ESM). " +
|
||||
"Pin it to ~2.4.0 (needs Node >=20.17) or ~2.2.10 in package.json, then reinstall"
|
||||
);
|
||||
}
|
||||
if (/Unsupported engine|EBADENGINE|requires Node|Node\.js version/i.test(msg)) {
|
||||
return "@sansenjian/qq-music-api 2.4.x requires Node >=20.17 (or >=22.9) — upgrade Node, or pin the package to ~2.2.10";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.once("error", () => {
|
||||
server.close(() => resolve(false));
|
||||
});
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
@@ -30,37 +62,50 @@ export function createApiServerManager(
|
||||
logger: Logger
|
||||
): ApiServerManager {
|
||||
let neteaseServer: Server | null = null;
|
||||
let qqMusicServer: Server | null = null;
|
||||
|
||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||
|
||||
return {
|
||||
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...");
|
||||
|
||||
// Start NetEase Cloud Music API
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
if (options.neteaseEnabled !== false) {
|
||||
try {
|
||||
const portFree = await isPortFree(options.neteasePort);
|
||||
if (!portFree) {
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
const ncmModule = await import("NeteaseCloudMusicApi") as any;
|
||||
const serverObj = ncmModule.server ?? ncmModule.default?.server;
|
||||
const app = await serverObj.serveNcmApi({ port: options.neteasePort });
|
||||
neteaseServer = app;
|
||||
logger.info(
|
||||
{ port: options.neteasePort },
|
||||
"NetEase Cloud Music API started"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start NetEase Cloud Music API");
|
||||
}
|
||||
|
||||
// Start QQ Music API (auto-starts on import)
|
||||
// Start QQ Music API. Older versions auto-started on import; the
|
||||
// current fork (2.2.11+) only listens when run as `require.main`,
|
||||
// so we explicitly call .listen() on the imported Koa app and keep
|
||||
// the server handle for clean shutdown.
|
||||
if (options.qqEnabled === false) return;
|
||||
try {
|
||||
const portFree = await isPortFree(options.qqMusicPort);
|
||||
if (!portFree) {
|
||||
@@ -69,17 +114,79 @@ export function createApiServerManager(
|
||||
"QQ Music API port already in use — reusing existing instance"
|
||||
);
|
||||
} else {
|
||||
await import("@sansenjian/qq-music-api");
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
// Pin the upstream server to the configured port before importing.
|
||||
// The package derives its default port from process.env.PORT (falling
|
||||
// back to 3200) and, in some historical versions, auto-started that
|
||||
// server as an import side effect. Aligning PORT with qqMusicApiPort
|
||||
// guarantees the sidecar can never bind a different port than the one
|
||||
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
|
||||
// issue #122, where an old build listened on 3300 while the client
|
||||
// requested 3200. Restore the previous value right after import so we
|
||||
// never leak the override into the rest of the process (e.g. the web
|
||||
// server or the NetEase sidecar, which also read PORT as a fallback).
|
||||
const prevPortEnv = process.env.PORT;
|
||||
process.env.PORT = String(options.qqMusicPort);
|
||||
let qqModule: any;
|
||||
try {
|
||||
qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||
} finally {
|
||||
if (prevPortEnv === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = prevPortEnv;
|
||||
}
|
||||
// The module's export structure varies between versions:
|
||||
// 2.2.11+: default → Koa app (has .listen)
|
||||
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||
// older: module itself may be the Koa app
|
||||
const candidate = qqModule.default ?? qqModule;
|
||||
const koaApp = typeof candidate.listen === "function"
|
||||
? candidate
|
||||
: candidate.default ?? null;
|
||||
if (koaApp && typeof koaApp.listen === "function") {
|
||||
// A version that auto-started on import has already bound the
|
||||
// configured port (thanks to the PORT alignment above); reuse it
|
||||
// rather than racing a second listen that would fail EADDRINUSE.
|
||||
const stillFree = await isPortFree(options.qqMusicPort);
|
||||
if (!stillFree) {
|
||||
logger.info(
|
||||
{ port: options.qqMusicPort },
|
||||
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
|
||||
);
|
||||
} else {
|
||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||
resolve(srv)
|
||||
);
|
||||
srv.on("error", reject);
|
||||
});
|
||||
// Log the port actually bound (read from the socket) rather than
|
||||
// the requested one, so operators can spot a mismatch in the logs.
|
||||
const addr = qqMusicServer.address();
|
||||
const boundPort =
|
||||
addr && typeof addr === "object" && addr !== null
|
||||
? addr.port
|
||||
: options.qqMusicPort;
|
||||
logger.info(
|
||||
{ port: boundPort },
|
||||
"QQ Music API started"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn("QQ Music API module does not expose a Koa app");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
const hint = describeQqApiStartupError(err);
|
||||
if (hint) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`QQ Music API failed to start — ${hint}. QQ features (search/play/login) will be unavailable until fixed; port ${options.qqMusicPort} is down.`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"QQ Music API not available — QQ Music features may be limited"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -89,6 +196,10 @@ export function createApiServerManager(
|
||||
(neteaseServer as any).close();
|
||||
}
|
||||
neteaseServer = null;
|
||||
if (qqMusicServer && typeof (qqMusicServer as any).close === "function") {
|
||||
(qqMusicServer as any).close();
|
||||
}
|
||||
qqMusicServer = null;
|
||||
},
|
||||
|
||||
getNeteaseBaseUrl(): string {
|
||||
|
||||
+8
-4
@@ -1,9 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
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 {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void;
|
||||
load(platform: "netease" | "qq" | "bilibili"): string;
|
||||
save(platform: CookiePlatform, cookie: string): void;
|
||||
load(platform: CookiePlatform): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
@@ -12,7 +16,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: "netease" | "qq" | "bilibili", cookie: string): void {
|
||||
save(platform: CookiePlatform, cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
@@ -21,7 +25,7 @@ export function createCookieStore(cookieDir: string): CookieStore {
|
||||
);
|
||||
},
|
||||
|
||||
load(platform: "netease" | "qq" | "bilibili"): string {
|
||||
load(platform: CookiePlatform): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
try {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+74
-9
@@ -1,7 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import type {
|
||||
MusicProvider,
|
||||
Song,
|
||||
SongUrlResult,
|
||||
Playlist,
|
||||
LyricLine,
|
||||
SearchResult,
|
||||
@@ -15,6 +17,16 @@ const BILIBILI_HEADERS = {
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key.
|
||||
const WBI_MIXIN_KEY_ENC_TAB = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
|
||||
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
|
||||
20, 34, 44, 52,
|
||||
];
|
||||
|
||||
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
|
||||
|
||||
export class BiliBiliProvider implements MusicProvider {
|
||||
readonly platform = "bilibili" as const;
|
||||
private api: AxiosInstance;
|
||||
@@ -24,6 +36,8 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
private cidCache = new Map<string, number>();
|
||||
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
|
||||
private buvidInitialized = false;
|
||||
private wbiMixinKey = "";
|
||||
private wbiKeyFetchedAt = 0;
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
@@ -62,6 +76,50 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
return combined ? { Cookie: combined } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the
|
||||
* mixin key used to sign search params. Required since B站 moved the
|
||||
* search endpoint behind wbi signing — unsigned /search/type now
|
||||
* returns an anti-bot HTML page.
|
||||
*/
|
||||
private async ensureWbiKeys(): Promise<void> {
|
||||
if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
const res = await this.api.get("/x/web-interface/nav", {
|
||||
headers: this.cookieHeaders,
|
||||
validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img
|
||||
});
|
||||
const wbi = res.data?.data?.wbi_img;
|
||||
const imgUrl: string = wbi?.img_url ?? "";
|
||||
const subUrl: string = wbi?.sub_url ?? "";
|
||||
const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? "";
|
||||
const subKey = subUrl.split("/").pop()?.split(".")[0] ?? "";
|
||||
if (!imgKey || !subKey) {
|
||||
throw new Error("Bilibili wbi keys unavailable");
|
||||
}
|
||||
const raw = imgKey + subKey;
|
||||
this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "")
|
||||
.join("")
|
||||
.slice(0, 32);
|
||||
this.wbiKeyFetchedAt = Date.now();
|
||||
}
|
||||
|
||||
/** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */
|
||||
private signWbi(params: Record<string, string | number>): Record<string, string> {
|
||||
const withTs: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) withTs[k] = String(v);
|
||||
withTs.wts = String(Math.floor(Date.now() / 1000));
|
||||
const sorted = Object.keys(withTs)
|
||||
.sort()
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`)
|
||||
.join("&");
|
||||
withTs.w_rid = createHash("md5")
|
||||
.update(sorted + this.wbiMixinKey)
|
||||
.digest("hex");
|
||||
return withTs;
|
||||
}
|
||||
|
||||
setQuality(quality: string): void {
|
||||
this.quality = quality;
|
||||
}
|
||||
@@ -89,14 +147,20 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
return fixed;
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
|
||||
await this.ensureBuvidCookie();
|
||||
const res = await this.api.get("/x/web-interface/search/type", {
|
||||
params: {
|
||||
search_type: "video",
|
||||
keyword: query,
|
||||
page_size: limit,
|
||||
},
|
||||
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({
|
||||
search_type: "video",
|
||||
keyword: query,
|
||||
page,
|
||||
page_size: limit,
|
||||
});
|
||||
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||
params: signed,
|
||||
headers: this.cookieHeaders,
|
||||
});
|
||||
|
||||
@@ -172,7 +236,7 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
return this.cidCache.get(bvid) ?? null;
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
|
||||
const cid = await this.getCid(songId);
|
||||
if (!cid) return null;
|
||||
|
||||
@@ -194,7 +258,8 @@ export class BiliBiliProvider implements MusicProvider {
|
||||
(b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a
|
||||
);
|
||||
|
||||
return best.baseUrl ?? best.base_url ?? null;
|
||||
const biliUrl = best.baseUrl ?? best.base_url;
|
||||
return biliUrl ? { url: biliUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
|
||||
import { parseLyrics } from "./netease.js";
|
||||
|
||||
describe("mapKugouSongs", () => {
|
||||
it("maps the mobile-search song shape to a Song with platform 'kugou'", () => {
|
||||
// Shape captured live from mobilecdn.kugou.com/api/v3/search/song.
|
||||
const raw = {
|
||||
hash: "B3A52A7A958BF0AED0EBFBA2E9A818B7",
|
||||
album_audio_id: 32100650,
|
||||
album_id: "966846",
|
||||
songname: "晴天",
|
||||
singername: "周杰伦",
|
||||
duration: 269,
|
||||
};
|
||||
const [song] = mapKugouSongs([raw]);
|
||||
expect(song.platform).toBe("kugou");
|
||||
expect(song.name).toBe("晴天");
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.duration).toBe(269);
|
||||
// The id must round-trip hash + album_audio_id + album_id so getSongUrl
|
||||
// can resolve a stream from a search result.
|
||||
expect(song.id).toBe("b3a52a7a958bf0aed0ebfba2e9a818b7|32100650|966846");
|
||||
});
|
||||
|
||||
it("treats nested audio_info durations as milliseconds and flat search durations as seconds", () => {
|
||||
// List endpoints: ms under audio_info.
|
||||
expect(mapKugouSong({ audio_info: { hash: "abc", duration: 215000 } }).duration).toBe(215);
|
||||
// Search endpoint: a long-form track's flat `duration` stays seconds (no /1000).
|
||||
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 10800 }).duration).toBe(10800);
|
||||
});
|
||||
|
||||
it("falls back to splitting '歌手 - 歌名' from the filename", () => {
|
||||
const song = mapKugouSong({ FileHash: "deadbeef", filename: "周杰伦 - 稻香", Duration: 200 });
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.name).toBe("稻香");
|
||||
});
|
||||
|
||||
it("uses PascalCase fields from the gateway shape and skips entries with no hash", () => {
|
||||
const songs = mapKugouSongs([
|
||||
{ FileHash: "aa", SongName: "n", SingerName: "s", MixSongID: 1, AlbumID: 2 },
|
||||
{ songname: "no hash" } as any,
|
||||
]);
|
||||
expect(songs).toHaveLength(1);
|
||||
expect(songs[0].id).toBe("aa|1|2");
|
||||
});
|
||||
|
||||
it("returns [] for non-array input", () => {
|
||||
expect(mapKugouSongs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps the NESTED album/playlist track shape (base + audio_info)", () => {
|
||||
// Shape captured live from /album/songs (叶惠美).
|
||||
const raw = {
|
||||
base: { album_id: 966846, album_audio_id: 32100648, audio_name: "以父之名", author_name: "周杰伦" },
|
||||
audio_info: { hash: "DBC0207490EB51153EF933EF5A7E98E4", duration: 342047 },
|
||||
};
|
||||
const [song] = mapKugouSongs([raw]);
|
||||
expect(song.name).toBe("以父之名");
|
||||
expect(song.artist).toBe("周杰伦");
|
||||
expect(song.duration).toBe(342); // ms → s
|
||||
expect(song.id).toBe("dbc0207490eb51153ef933ef5a7e98e4|32100648|966846");
|
||||
});
|
||||
});
|
||||
|
||||
describe("krcToLrc", () => {
|
||||
it("converts KRC [startMs,durMs] line timestamps + strips word timings into parseable LRC", () => {
|
||||
// Shape captured live from lyrics.kugou.com (周杰伦 - 晴天).
|
||||
const krc = [
|
||||
"[ti:晴天]",
|
||||
"[ar:周杰伦]",
|
||||
"[0,2250]<0,160,0>晴<160,160,0>天",
|
||||
"[63000,1800]<0,200,0>故<200,200,0>事",
|
||||
].join("\n");
|
||||
const lrc = krcToLrc(krc);
|
||||
expect(lrc).toContain("[00:00.00]晴天");
|
||||
expect(lrc).toContain("[01:03.00]故事");
|
||||
expect(lrc).not.toMatch(/<\d+,\d+,\d+>/); // word timings stripped
|
||||
|
||||
// And the shared LRC parser must now actually produce timed lines.
|
||||
const lines = parseLyrics(lrc);
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toEqual({ time: 0, text: "晴天" });
|
||||
expect(lines[1].time).toBe(63);
|
||||
expect(lines[1].text).toBe("故事");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouAlbums", () => {
|
||||
it("maps album shape and normalises {size} covers to https", () => {
|
||||
const [album] = mapKugouAlbums([
|
||||
{ album_id: 966846, album_name: "叶惠美", singername: "周杰伦", sizable_cover: "http://imge.kugou.com/x/{size}/abc.jpg", songcount: 11 },
|
||||
]);
|
||||
expect(album.platform).toBe("kugou");
|
||||
expect(album.id).toBe("966846");
|
||||
expect(album.name).toBe("叶惠美");
|
||||
expect(album.coverUrl).toBe("https://imge.kugou.com/x/240/abc.jpg");
|
||||
expect(album.songCount).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — playlist (get_other_list_file) shape", () => {
|
||||
it("splits the combined `name` and uses `mixsongid` as the audio id", () => {
|
||||
// Shape captured live from /pubsongs/v2/get_other_list_file_nofilt (我喜欢).
|
||||
// The combined "歌手 - 歌名" is in `name`; there is no separate songname.
|
||||
const raw = {
|
||||
name: "KOKIA - ありがとう… (谢谢…)",
|
||||
hash: "E9A08A98614DD992F11A68A5E5F1C79F",
|
||||
album_id: "1491689",
|
||||
mixsongid: 37533796,
|
||||
timelen: 248528, // ms
|
||||
cover: "http://imge.kugou.com/stdmusic/{size}/20210113/x.jpg",
|
||||
};
|
||||
const song = mapKugouSong(raw);
|
||||
expect(song.artist).toBe("KOKIA");
|
||||
expect(song.name).toBe("ありがとう… (谢谢…)");
|
||||
// hash lowercased | mixsongid | album_id — so getSongUrl can resolve it.
|
||||
expect(song.id).toBe("e9a08a98614dd992f11a68a5e5f1c79f|37533796|1491689");
|
||||
expect(song.duration).toBe(249); // timelen ms → s
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/20210113/x.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouSong — cover art per endpoint", () => {
|
||||
it("reads `sizable_cover` (daily/FM shape) and resolves {size}→240, http→https", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "http://imge.kugou.com/stdmusic/{size}/y.jpg",
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/y.jpg");
|
||||
});
|
||||
|
||||
it("falls back to `trans_param.union_cover` (search shape, no top-level cover)", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
|
||||
it("returns an empty coverUrl when no cover field is present", () => {
|
||||
expect(mapKugouSong({ hash: "abc", songname: "x", duration: 200 }).coverUrl).toBe("");
|
||||
});
|
||||
|
||||
it("skips an empty-string cover field (Kugou returns '') and uses the next non-empty source", () => {
|
||||
const song = mapKugouSong({
|
||||
hash: "abc",
|
||||
songname: "x",
|
||||
duration: 200,
|
||||
sizable_cover: "", // present but empty — must NOT mask the real cover below
|
||||
trans_param: { union_cover: "http://imge.kugou.com/stdmusic/{size}/z.jpg" },
|
||||
});
|
||||
expect(song.coverUrl).toBe("https://imge.kugou.com/stdmusic/240/z.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapKugouPlaylists", () => {
|
||||
it("maps the user-playlist shape (/v7/get_all_list info), keying id on global_collection_id", () => {
|
||||
// Shape captured live from /v7/get_all_list (我喜欢).
|
||||
const [pl] = mapKugouPlaylists([
|
||||
{ name: "我喜欢", count: 7, global_collection_id: "collection_3_2526507197_2_0", listid: 2, type: 0 },
|
||||
]);
|
||||
expect(pl.platform).toBe("kugou");
|
||||
expect(pl.name).toBe("我喜欢");
|
||||
expect(pl.songCount).toBe(7);
|
||||
// Must be the global_collection_id — the only id getPlaylistSongs can open.
|
||||
expect(pl.id).toBe("collection_3_2526507197_2_0");
|
||||
});
|
||||
|
||||
it("maps the recommend shape (/v2/special_recommend), preferring global_collection_id over specialid", () => {
|
||||
// Shape captured live from /v2/special_recommend special_list.
|
||||
const pl = mapKugouPlaylist({
|
||||
specialname: "米津玄师:蒙着眼睛也能炸翻全场",
|
||||
specialid: 1154672,
|
||||
global_collection_id: "collection_1_1029965246_1154672_0",
|
||||
pic: "http://imge.kugou.com/specialimg/{size}/a.jpg",
|
||||
percount: 0,
|
||||
});
|
||||
expect(pl.id).toBe("collection_1_1029965246_1154672_0");
|
||||
expect(pl.name).toBe("米津玄师:蒙着眼睛也能炸翻全场");
|
||||
expect(pl.coverUrl).toBe("https://imge.kugou.com/specialimg/240/a.jpg");
|
||||
});
|
||||
|
||||
it("drops entries whose only id is a non-openable specialid/listid (not a global_collection_id)", () => {
|
||||
// getPlaylistSongs can only open a global_collection_id, so a numeric
|
||||
// specialid/listid would be a dead id — such entries must be dropped.
|
||||
expect(mapKugouPlaylists([{ specialname: "x", specialid: 1154672, listid: 9 }])).toHaveLength(0);
|
||||
expect(mapKugouPlaylists([{ name: "no id" }])).toHaveLength(0);
|
||||
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);
|
||||
});
|
||||
});
|
||||
+1031
File diff suppressed because it is too large.
Load diff
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user