Compare commits

...
Author SHA1 Message Date
TIANYAO ZHANG 7fc5186c24 Merge pull request #65 from ZHANGTIANYAO1/fix/bilibili-wbi-search
fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
2026-05-16 22:31:55 +08:00
saopig1andClaude Opus 4.7 de92c8bcd4 fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint
behind their anti-bot wall; it now returns an HTML error page (出错啦!) even
with buvid3+buvid4 cookies, causing `play -b` to report "No results found".

Switch search() to /x/web-interface/wbi/search/type with proper wbi signing:
fetch img_key/sub_key from /nav, derive the mixin key via the standard
permutation, and sign each request with wts + w_rid (md5). Keys are cached
for 6h since they rotate ~daily. Other endpoints (view, playurl, popular,
top/rcmd) still work unsigned and are left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:29:12 +08:00
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00
saopig1andClaude Opus 4.7 c562fe05b0 fix(player): start frame loop only after ffmpeg spawns in jdymusic path
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.

Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:04:27 +08:00
TIANYAO ZHANG 9efa818bbb Merge pull request #57 from ZHANGTIANYAO1/feat/album-search
feat(search): album section + album playback
2026-05-07 20:50:02 +08:00
TIANYAO ZHANG d757e69bf4 Merge pull request #56 from ZHANGTIANYAO1/feat/custom-bot-avatar
feat(profile): custom bot avatar
2026-05-07 20:49:58 +08:00
saopig1andClaude Opus 4.7 88ff62c829 fix(profile): apply custom avatar immediately on idle setCustomAvatar / connect
Review feedback on PR #56:

1. setCustomAvatar(buf) now triggers applyIdleAvatar when the bot is idle
   (currentSong=null OR avatarEnabled=false). Spec said this; original impl
   only stored the buffer, so a fresh upload from Settings was invisible
   until next stop event. Track currentSong in BotProfileManager for the
   idle check.

2. onConnect drops the !avatarEnabled guard — on a fresh connect there's
   no song playing yet, so the spec matrix wants the custom avatar shown
   regardless of sync. Previously bots reconnected with a stale TS3
   server-side avatar.

3. CustomAvatarRow: defer the initializing=false flip to nextTick so the
   load-time data-url assignment's queued watcher sees initializing=true
   and bails. Removes the redundant PUT-on-mount that echoed the just-
   loaded bytes back to the server.

4. BotInstance avatar load wrapped in try/catch — a corrupt/locked file
   no longer crashes startup; we log and continue with no custom avatar.

Tests rewritten: 10 cases covering the full behavior matrix
(idle vs playing × sync on/off × custom set/null × stop/connect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:40:00 +08:00
saopig1andClaude Sonnet 4.6 0f9c7b3c4c feat(web): custom avatar in create-bot + Settings
Adds AvatarUpload to the create-bot form (PUT on new bot id after POST)
and a CustomAvatarRow per-bot in the profile-toggles section (GET on
mount, PUT/DELETE on user action with initializing guard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:14:34 +08:00
saopig1andClaude Sonnet 4.6 914180792d feat(web): AvatarUpload component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:11:43 +08:00
saopig1andClaude Sonnet 4.6 935656dc4f feat(api): /api/bot/:id/avatar GET/PUT/DELETE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:10:06 +08:00
saopig1andClaude Sonnet 4.6 2dd6a9e20d feat(bot): load custom avatar on instance startup
Thread AvatarStore from index.ts → BotManager → BotInstance so every
BotInstance reads the persisted custom avatar from disk at construction
time and hands it to BotProfileManager via setCustomAvatar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:07:47 +08:00
saopig1andClaude Sonnet 4.6 ffa27d7224 feat(profile): custom avatar with idle/playback precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:05:00 +08:00
saopig1andClaude Sonnet 4.6 edd0fc58eb feat(data): avatar file store helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:00:56 +08:00
saopig1andClaude Sonnet 4.6 366edf7843 feat(db): custom_avatar_path column + accessors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:20 +08:00
26 changed files with 1547 additions and 230 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
+1
View File
@@ -253,6 +253,7 @@ sudo systemctl start tsmusicbot
| `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
+161
View File
@@ -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);
}
+87 -114
View File
@@ -4,14 +4,13 @@ chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Robust Edition)
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step
:: - Detailed logging to setup.log
:: - Skip already-completed steps on re-run
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
@@ -44,42 +43,29 @@ echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/6" "Checking Node.js"
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if errorlevel 1 (
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
if not errorlevel 1 goto :check_node_version
:: Check Node version >= 20
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
)
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
:: Robust version parse
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
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%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause
exit /b 1
)
@@ -88,12 +74,11 @@ echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/6" "Checking npm"
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this.
call :error "npm not found."
pause
exit /b 1
)
@@ -106,117 +91,91 @@ echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/6" "Checking network"
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS)
where curl >nul 2>&1
if not errorlevel 1 (
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else (
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!"
set "USE_MIRROR=1"
)
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 (
:: Fallback to ping
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
)
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
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
:: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================
call :step "4/6" "Installing backend dependencies"
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
)
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
echo Press Ctrl+C to abort.
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install >>"%LOG_FILE%" 2>&1
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo.
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo.
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/6" "Installing frontend dependencies"
call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo Running: npm install (in web/)
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1
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 ^(exit code !WEB_INSTALL_RESULT!^)."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
call :error "Frontend npm install failed."
pause
exit /b 1
)
@@ -224,29 +183,48 @@ echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: Step 6: Build project
:: ============================================================
call :step "6/6" "Building 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."
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo.
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
:: ============================================================
@@ -263,18 +241,13 @@ if not exist "web\dist" (
)
if "!BUILD_OK!"=="0" (
echo.
echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
@@ -294,9 +267,8 @@ echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo.
echo Setup log saved to: %LOG_FILE%
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
@@ -319,3 +291,4 @@ goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+145
View File
@@ -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 ""
+17 -20
View File
@@ -7,7 +7,7 @@ echo.
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
echo Run scripts\setup.bat first.
pause
exit /b 1
)
@@ -15,33 +15,30 @@ if %errorlevel% neq 0 (
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
:: Check if dependencies are installed
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
)
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Build if dist/ doesn't exist
:: Check if build output exists
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
echo Build not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: 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
+8 -1
View File
@@ -279,8 +279,12 @@ export class AudioPlayer extends EventEmitter {
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";
this.startFrameLoop();
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
@@ -329,6 +333,9 @@ export class AudioPlayer extends EventEmitter {
this.emit("error", err);
}
});
// Now that ffmpeg is producing PCM, run the frame loop.
this.startFrameLoop();
}
stop(): void {
+47 -24
View File
@@ -22,6 +22,7 @@ export class PlayQueue {
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 {
@@ -100,12 +101,14 @@ export class PlayQueue {
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];
@@ -118,6 +121,7 @@ export class PlayQueue {
// 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];
@@ -139,32 +143,45 @@ export class PlayQueue {
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
// 优先回到前进栈记录的位置(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];
}
}
// 前进栈为空,走纯随机逻辑
if (this.mode === PlayMode.Random) {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
} else {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
@@ -172,13 +189,18 @@ export class PlayQueue {
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.add(idx);
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
@@ -227,6 +249,7 @@ export class PlayQueue {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
+36 -2
View File
@@ -16,6 +16,7 @@ import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
export interface BotInstanceOptions {
id: string;
@@ -28,6 +29,7 @@ export interface BotInstanceOptions {
database: BotDatabase;
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
}
export interface BotStatus {
@@ -57,6 +59,7 @@ export class BotInstance extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private connected = false;
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
@@ -77,6 +80,7 @@ export class BotInstance extends EventEmitter {
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
this.avatarStore = options.avatarStore;
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
@@ -90,6 +94,17 @@ export class BotInstance extends EventEmitter {
options.tsOptions.nickname,
);
// Best-effort: a corrupted/locked avatar file must not block bot startup.
try {
const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
} catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
}
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -623,9 +638,27 @@ export class BotInstance extends EventEmitter {
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>";
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args);
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let albumId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
const albums = result.albums ?? [];
if (albums.length === 0)
return `No albums found for: ${cmd.args}`;
albumId = albums[0].id;
}
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
@@ -763,6 +796,7 @@ export class BotInstance extends EventEmitter {
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
+10 -3
View File
@@ -11,6 +11,7 @@ import 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";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -74,6 +75,7 @@ export class BotManager extends EventEmitter {
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
constructor(
neteaseProvider: MusicProvider,
@@ -81,7 +83,8 @@ export class BotManager extends EventEmitter {
bilibiliProvider: MusicProvider,
database: BotDatabase,
config: BotConfig,
logger: Logger
logger: Logger,
avatarStore: AvatarStore
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -91,6 +94,7 @@ export class BotManager extends EventEmitter {
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -117,6 +121,7 @@ export class BotManager extends EventEmitter {
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
@@ -229,10 +234,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
@@ -279,10 +285,11 @@ export class BotManager extends EventEmitter {
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
youtubeProvider: this.youtubeProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
});
this.bots.set(saved.id, bot);
+147
View File
@@ -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);
});
});
+51 -1
View File
@@ -25,6 +25,13 @@ export class BotProfileManager {
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 = {
@@ -58,6 +65,26 @@ export class BotProfileManager {
// --- 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).
@@ -71,6 +98,7 @@ export class BotProfileManager {
*/
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
@@ -91,6 +119,7 @@ export class BotProfileManager {
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.currentSong = null;
this.permDenied = {
avatar: false,
description: false,
@@ -99,6 +128,12 @@ export class BotProfileManager {
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 {
@@ -171,6 +206,10 @@ export class BotProfileManager {
}
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"]),
@@ -179,7 +218,6 @@ export class BotProfileManager {
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
@@ -188,6 +226,18 @@ export class BotProfileManager {
}
}
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 {
+61
View File
@@ -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);
});
});
+43
View File
@@ -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;
},
};
}
+22
View File
@@ -98,4 +98,26 @@ describe("database", () => {
expect(botDb.getBotInstances()).toHaveLength(0);
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
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();
});
});
+16
View File
@@ -60,6 +60,8 @@ export interface BotDatabase {
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
close(): void;
}
@@ -92,6 +94,9 @@ function migrateSchema(db: Database.Database): void {
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");
}
}
function initTables(db: Database.Database): void {
@@ -179,6 +184,9 @@ export function createDatabase(dbPath: string): BotDatabase {
WHERE id = @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 = ?`);
return {
db,
@@ -242,6 +250,14 @@ export function createDatabase(dbPath: string): BotDatabase {
});
},
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);
},
close() {
db.close();
},
+6 -1
View File
@@ -8,6 +8,7 @@ import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
@@ -18,6 +19,7 @@ const 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 STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
@@ -46,6 +48,7 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider();
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");
@@ -59,7 +62,8 @@ async function main() {
bilibiliProvider,
db,
config,
logger
logger,
avatarStore
);
await botManager.loadSavedBots();
@@ -70,6 +74,7 @@ async function main() {
qqProvider,
bilibiliProvider,
database: db,
avatarStore,
config,
configPath: CONFIG_PATH,
logger,
+65 -6
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
@@ -15,6 +16,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 +35,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 +75,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;
}
@@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider {
async search(query: string, limit = 20): 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();
const signed = this.signWbi({
search_type: "video",
keyword: query,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
params: signed,
headers: this.cookieHeaders,
});
+2 -2
View File
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
params: {
keywords: query,
type: 1000,
limit: 5,
limit: 10,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}),
]);
+133 -27
View File
@@ -12,14 +12,25 @@ import type {
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// batch). Was broken ca. 2026-05 due to two upstream API changes:
// 1. searchid param must NOT be present (causes all lists to be empty)
// 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// endpoint, song + album only, no playlist support).
const qqSearchApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
@@ -82,37 +93,132 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
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 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary;
const songs: Song[] = list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
*
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
): Promise<SearchResult | null> {
try {
const numPerPage = Math.max(10, Math.min(limit, 50));
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const songList: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs: Song[] = songList.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch {
return null;
}
}
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
): Promise<SearchResult> {
const songParams = {
w: query,
format: "json",
p: 1,
n: Math.min(limit, 50),
type: 0,
cr: 1,
};
const albumParams = {
w: query,
format: "json",
p: 1,
n: 5,
t: 8,
cr: 1,
};
const [songRes, albumRes] = await Promise.allSettled([
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
+69 -1
View File
@@ -3,12 +3,16 @@ import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js";
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
const router = Router();
@@ -36,6 +40,70 @@ export function createBotRouter(
res.json(saved);
});
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 exists =
botManager.getBot(req.params.id) ||
botDb.getBotInstances().some((b) => b.id === req.params.id);
if (!exists) {
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] as string;
const buf = Buffer.from(m[2] ?? "", "base64");
if (buf.length === 0) {
res.status(400).json({ error: "empty image" });
return;
}
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);
botManager.getBot(req.params.id)?.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);
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
router.post("/", async (req, res) => {
try {
const {
+11 -2
View File
@@ -8,6 +8,7 @@ import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { CookieStore } from "../music/auth.js";
import type { AvatarStore } from "../data/avatars.js";
import { createBotRouter } from "./api/bot.js";
import { createMusicRouter } from "./api/music.js";
import { createPlayerRouter } from "./api/player.js";
@@ -25,6 +26,7 @@ export interface WebServerOptions {
configPath: string;
logger: Logger;
cookieStore?: CookieStore;
avatarStore: AvatarStore;
staticDir?: string;
}
@@ -43,7 +45,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.set("trust proxy", true);
}
app.use(express.json());
app.use(express.json({ limit: "400kb" }));
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
@@ -52,7 +54,14 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use(
"/api/bot",
createBotRouter(options.botManager, options.config, options.configPath, logger)
createBotRouter(
options.botManager,
options.config,
options.configPath,
logger,
options.database,
options.avatarStore,
)
);
app.use(
"/api/music",
+99
View File
@@ -0,0 +1,99 @@
<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; }
.btn-sm {
padding: 6px 14px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
transition: all var(--transition-fast);
&:hover { background: var(--color-primary); color: white; }
}
.btn-danger {
&:hover { background: #f44336; color: white; }
}
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: #f44336; opacity: 1; }
</style>
+59
View File
@@ -0,0 +1,59 @@
<template>
<AvatarUpload v-model="avatarDataUrl" />
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import axios from 'axios';
import AvatarUpload from './AvatarUpload.vue';
const props = defineProps<{ botId: string }>();
const avatarDataUrl = ref<string | null>(null);
// Stays true until the watcher queued by the load-time assignment has run,
// so the initial null → loaded-data-url transition does not fire a redundant
// PUT echoing the just-fetched bytes back to the server.
let initializing = true;
async function loadCurrent() {
try {
const res = await axios.get(`/api/bot/${props.botId}/avatar`, { responseType: 'blob' });
const blob = res.data as Blob;
avatarDataUrl.value = await blobToDataUrl(blob);
} catch (err: any) {
if (err?.response?.status !== 404) {
console.warn('failed to load avatar', err);
}
avatarDataUrl.value = null;
} finally {
// Wait for the watcher's flush queue to drain (it'll see initializing=true
// and bail), then release for real user-driven changes.
await nextTick();
initializing = false;
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
watch(avatarDataUrl, async (newVal, oldVal) => {
if (initializing) return;
if (newVal === oldVal) return;
try {
if (newVal && newVal.startsWith('data:')) {
await axios.put(`/api/bot/${props.botId}/avatar`, { dataUrl: newVal });
} else if (newVal === null) {
await axios.delete(`/api/bot/${props.botId}/avatar`);
}
} catch (err) {
console.warn('avatar update failed', err);
}
});
onMounted(loadCurrent);
</script>
+219 -23
View File
@@ -20,42 +20,89 @@
<div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="songs.length || albums.length || playlists.length">
<section v-if="albums.length" class="result-section">
<h2 class="section-title">专辑</h2>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<div class="source-bar">
<button
class="source-btn"
:class="{ active: selectedSource === 'netease' }"
@click="selectedSource = 'netease'"
>网易云</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'qq' }"
@click="selectedSource = 'qq'"
>QQ</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
</div>
<div class="tab-bar">
<button
class="tab"
:class="{ active: activeTab === 'songs' }"
@click="activeTab = 'songs'"
>
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
>
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
>
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
</button>
</div>
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
<div class="card-grid">
<router-link
v-for="al in albums"
v-for="al in filteredAlbums"
: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-name">
{{ al.name }}
<span class="platform-badge" :class="badgeClass(al.platform)">{{ badgeLabel(al.platform) }}</span>
</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>
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<div class="card-grid">
<router-link
v-for="pl in playlists"
v-for="pl in filteredPlaylists"
: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>
<div class="card-name">
{{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
</div>
</router-link>
</div>
</section>
<section v-if="songs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<SongCard
v-for="(song, i) in songs"
v-for="(song, i) in filteredSongs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
@@ -72,8 +119,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
@@ -83,34 +130,87 @@ import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; 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 allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
);
const filteredAlbums = computed(() =>
allAlbums.value.filter((a) => a.platform === selectedSource.value)
);
const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
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 ?? [];
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
} catch {
songs.value = []; albums.value = []; playlists.value = [];
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally {
loading.value = false;
}
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
return '网易云';
}
function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
return 'badge-netease';
}
onMounted(() => {
if (query.value) doSearch();
});
@@ -180,14 +280,80 @@ onMounted(() => {
gap: 2px;
}
.source-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.source-btn {
padding: 5px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
}
.tab-bar {
display: flex;
gap: 6px;
margin-bottom: 24px;
padding: 4px;
background: var(--bg-card);
border-radius: var(--radius-md);
width: fit-content;
}
.tab {
padding: 8px 20px;
border-radius: calc(var(--radius-md) - 2px);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
background: var(--color-primary);
color: #fff;
.tab-count { opacity: 0.85; }
}
}
.tab-count {
margin-left: 5px;
opacity: 0.55;
font-weight: var(--fw-regular);
font-size: 13px;
&::before { content: '('; }
&::after { content: ')'; }
}
.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;
gap: 16px 28px;
}
.card {
display: flex;
@@ -198,4 +364,34 @@ onMounted(() => {
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
.platform-badge {
vertical-align: middle;
flex-shrink: 0;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
</style>
+32 -1
View File
@@ -80,6 +80,10 @@
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="form-group">
<label>自定义头像</label>
<CustomAvatarRow :bot-id="editingBot" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
@@ -116,6 +120,10 @@
<label>服务器密码(可选)</label>
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="form-group">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
<button class="btn-primary" @click="createBot">创建</button>
</div>
</section>
@@ -439,6 +447,13 @@
@change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)"
/>
</label>
<div v-if="profileConfigs[bot.id]" class="profile-toggle profile-toggle-static">
<div class="profile-toggle-text">
<div class="profile-toggle-label">自定义头像</div>
<div class="profile-toggle-hint">无论封面同步是否开启,停播时都会回到这张图</div>
</div>
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
</div>
</div>
@@ -450,6 +465,8 @@
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import AvatarUpload from '../components/AvatarUpload.vue';
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
@@ -475,6 +492,7 @@ const newBotPort = ref(9987);
const newBotNickname = ref('MusicBot');
const newBotChannel = ref('');
const newBotServerPassword = ref('');
const newBotAvatar = ref<string | null>(null);
// Edit bot
const editingBot = ref<string | null>(null);
@@ -635,7 +653,7 @@ async function pollQrStatus(platform: string) {
async function createBot() {
if (!newBotName.value || !newBotServer.value) return;
try {
await axios.post('/api/bot', {
const res = await axios.post('/api/bot', {
name: newBotName.value,
serverAddress: newBotServer.value,
serverPort: newBotPort.value || 9987,
@@ -644,12 +662,20 @@ async function createBot() {
serverPassword: newBotServerPassword.value || undefined,
autoStart: false,
});
if (newBotAvatar.value && res.data?.id) {
try {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
} catch (err) {
console.warn('failed to set avatar on new bot', err);
}
}
newBotName.value = '';
newBotServer.value = '';
newBotPort.value = 9987;
newBotNickname.value = 'MusicBot';
newBotChannel.value = '';
newBotServerPassword.value = '';
newBotAvatar.value = null;
await store.fetchBots();
} catch {
// Ignore
@@ -1408,6 +1434,11 @@ onUnmounted(() => {
}
}
.profile-toggle-static {
cursor: default;
align-items: flex-start;
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;