Compare commits

...
15 Commits
Author SHA1 Message Date
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
11 changed files with 1066 additions and 447 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)" "Read(//tmp/**)"
], ],
"deny": [ "deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)", "Bash(git push --force *)",
"Bash(rm -rf /)" "Bash(rm -rf /)"
] ]
+1
View File
@@ -253,6 +253,7 @@ sudo systemctl start tsmusicbot
| `!stop` | 停止播放并清空队列 | | `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 | | `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 | | `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 | | `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) | | `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 | | `!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);
}
+294 -321
View File
@@ -1,321 +1,294 @@
@echo off @echo off
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
chcp 65001 >nul chcp 65001 >nul
title TSMusicBot Setup title TSMusicBot Setup
:: ============================================================ :: ============================================================
:: TSMusicBot Setup Script (Robust Edition) :: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror :: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step :: - Download native binaries from CDN (避开 GitHub)
:: - Detailed logging to setup.log :: - 自动修复 PowerShell 环境变量
:: - Skip already-completed steps on re-run :: ============================================================
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "SCRIPT_VERSION=2.0" set "MIN_NODE_MAJOR=20"
set "MIN_NODE_MAJOR=20" set "LOG_FILE=%~dp0..\setup.log"
set "LOG_FILE=%~dp0..\setup.log" set "FAILED=0"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
:: Resolve project root (one level up from scripts/) cd /d "%~dp0.." || (
cd /d "%~dp0.." || ( echo [FATAL] Cannot change to project directory.
echo [FATAL] Cannot change to project directory. pause
pause exit /b 1
exit /b 1 )
)
set "PROJECT_ROOT=%cd%"
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
:: ---- Initialize log ---- echo. > "%LOG_FILE%"
echo. > "%LOG_FILE%" call :log "============================================"
call :log "============================================" call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%" call :log " Started: %date% %time%"
call :log " Started: %date% %time%" call :log " Project root: %PROJECT_ROOT%"
call :log " Project root: %PROJECT_ROOT%" call :log "============================================"
call :log "============================================"
echo ============================================
echo ============================================ echo TSMusicBot - First-Time Setup (Windows)
echo TSMusicBot - First-Time Setup (Windows) echo Version %SCRIPT_VERSION%
echo Version %SCRIPT_VERSION% echo ============================================
echo ============================================ echo.
echo. echo Log file: %LOG_FILE%
echo Log file: %LOG_FILE% echo.
echo.
:: ============================================================
:: ============================================================ :: Step 1: Check Node.js
:: Step 1: Check Node.js :: ============================================================
:: ============================================================ call :step "1/7" "Checking Node.js"
call :step "1/6" "Checking Node.js"
where node >nul 2>&1
where node >nul 2>&1 if not errorlevel 1 goto :check_node_version
if errorlevel 1 (
call :error "Node.js not found in PATH." call :error "Node.js not found in PATH."
echo. echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of: echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
echo - https://nodejs.org/ ^(official^) echo https://nodejs.org/ (official)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^) echo https://nodejs.cn/ (China mirror, recommended)
echo. echo.
echo After installation: pause
echo 1. Close this window completely exit /b 1
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again :check_node_version
echo. for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
pause for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
exit /b 1
) call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
:: Check Node version >= 20
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a" if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do ( call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b" pause
) exit /b 1
)
:: Robust version parse echo.
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" :: Step 2: Check npm
:: ============================================================
call :log "Node.js version: %NODE_VER%" call :step "2/7" "Checking npm"
echo [OK] Node.js found: %NODE_VER%
where npm >nul 2>&1
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% ( if errorlevel 1 (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%." call :error "npm not found."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer. pause
pause exit /b 1
exit /b 1 )
)
echo. 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%
:: Step 2: Check npm echo.
:: ============================================================
call :step "2/6" "Checking npm" :: ============================================================
:: Step 3: Detect network and configure mirror
where npm >nul 2>&1 :: ============================================================
if errorlevel 1 ( call :step "3/7" "Checking network"
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this. set "USE_MIRROR=0"
pause set "MIRROR_REGISTRY=https://registry.npmjs.org"
exit /b 1
) echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%" ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
echo [OK] npm found: %NPM_VER% if errorlevel 1 (
echo. echo [WARN] Cannot reach npm registry quickly, using China mirror.
call :log "npm registry unreachable via ping"
:: ============================================================ set "USE_MIRROR=1"
:: Step 3: Detect network and configure mirror ) else (
:: ============================================================ echo [OK] npm registry reachable.
call :step "3/6" "Checking network" call :log "npm registry reachable"
)
set "USE_MIRROR=0"
if "%USE_MIRROR%"=="1" (
:: Try reaching npm registry with a short timeout echo.
echo Testing connection to registry.npmjs.org... echo [INFO] Using China mirror (npmmirror.com)
call :log "Testing npm registry connectivity..." call :log "Switching to npmmirror.com"
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
:: Use curl if available (more reliable than ping for HTTPS) set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
where curl >nul 2>&1 ) else (
if not errorlevel 1 ( set "CDN_MIRROR="
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" echo.
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" ( :: ============================================================
echo [OK] npm registry reachable. :: Step 4: Install backend dependencies (跳过二进制)
call :log "npm registry HTTP 200 OK" :: ============================================================
) else ( call :step "4/7" "Installing backend dependencies"
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!" if exist "node_modules\.package-lock.json" (
set "USE_MIRROR=1" echo Found existing node_modules. Checking integrity...
) )
) else (
:: Fallback to ping echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1 echo.
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly. call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
set "USE_MIRROR=1" if errorlevel 1 (
) else ( call :error "Backend npm install failed."
echo [OK] npm registry reachable. echo Check the log: %LOG_FILE%
) pause
) exit /b 1
)
if "%USE_MIRROR%"=="1" ( echo [OK] Backend dependencies installed.
echo. echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
call :log "Switching to npmmirror.com" :: ============================================================
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1 :: Step 4b: Download native binaries from CDN
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 :step "4b/7" "Downloading native binaries"
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 node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1 if errorlevel 1 (
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1 echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1 ) else (
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1 echo [OK] Native binaries installed.
echo [OK] Mirror configured. )
) echo.
echo.
:: ============================================================
:: ============================================================ :: Step 5: Install frontend dependencies
:: Step 4: Install backend dependencies :: ============================================================
:: ============================================================ call :step "5/7" "Installing frontend dependencies"
call :step "4/6" "Installing backend dependencies"
if not exist "web\package.json" (
if exist "node_modules\.package-lock.json" ( call :error "web\package.json not found."
echo Found existing node_modules. Checking integrity... pause
call :log "Existing node_modules detected, running npm install to verify" exit /b 1
) )
echo Running: npm install ^(this can take 5-15 minutes on slow networks^) echo Running: npm install (in web/)
echo Press Ctrl+C to abort. echo.
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1 call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
if errorlevel 1 ( set "WEB_INSTALL_RESULT=!errorlevel!"
call :error "Backend npm install failed." popd >nul
echo.
echo Common causes: if !WEB_INSTALL_RESULT! neq 0 (
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^) call :error "Frontend npm install failed."
echo - Native module compile failure ^(missing Python/VS Build Tools^) pause
echo - Disk space full exit /b 1
echo. )
echo Try manually: echo [OK] Frontend dependencies installed.
echo cd /d "%PROJECT_ROOT%" echo.
echo npm install --verbose
echo. :: ============================================================
pause :: Step 6: Build project
exit /b 1 :: ============================================================
) call :step "6/7" "Building project"
echo [OK] Backend dependencies installed.
echo. echo Running: npm run build
echo.
:: ============================================================
:: Step 5: Install frontend dependencies call npm run build >>"%LOG_FILE%" 2>&1
:: ============================================================ if errorlevel 1 (
call :step "5/6" "Installing frontend dependencies" call :error "Build failed. Check: %LOG_FILE%"
pause
if not exist "web\package.json" ( exit /b 1
call :error "web\package.json not found. Repository may be incomplete." )
echo Please re-clone the repository: echo [OK] Build succeeded.
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git echo.
pause
exit /b 1 :: ============================================================
) :: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
echo Running: npm install ^(in web/ directory^) call :step "7/7" "Checking PowerShell PATH"
echo.
where powershell >nul 2>&1
pushd web >nul if errorlevel 1 (
call npm install >>"%LOG_FILE%" 2>&1 echo [WARN] PowerShell not found in PATH.
set "WEB_INSTALL_RESULT=!errorlevel!" echo Attempting to fix...
popd >nul set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
if !WEB_INSTALL_RESULT! neq 0 ( :: 为用户添加永久 PATH 环境变量
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)." echo [INFO] Adding PowerShell to user PATH...
echo. call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo Try manually: echo [OK] PowerShell added to PATH. Please restart your terminal.
echo cd /d "%PROJECT_ROOT%\web" ) else (
echo npm install --verbose echo [WARN] Could not find powershell.exe on this system.
echo. echo If you encounter playback issues with some NetEase songs,
pause echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
exit /b 1 echo before running scripts\start.bat
) )
echo [OK] Frontend dependencies installed. ) else (
echo. echo [OK] PowerShell found in PATH.
)
:: ============================================================ echo.
:: Step 6: Build project (backend + frontend)
:: ============================================================ :: ============================================================
call :step "6/6" "Building project" :: Verify build outputs
:: ============================================================
echo Running: npm run build echo Verifying build outputs...
echo. set "BUILD_OK=1"
call npm run build >>"%LOG_FILE%" 2>&1 if not exist "dist" (
if errorlevel 1 ( call :error "dist/ directory missing after build."
call :error "Build failed." set "BUILD_OK=0"
echo. )
echo Check the log file for details: %LOG_FILE% if not exist "web\dist" (
echo. call :error "web\dist/ directory missing after build."
echo Try manually: set "BUILD_OK=0"
echo cd /d "%PROJECT_ROOT%" )
echo npm run build
echo. if "!BUILD_OK!"=="0" (
pause echo Build completed but expected output is missing.
exit /b 1 pause
) exit /b 1
echo [OK] Build succeeded. )
echo. echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Verify build outputs if not exist "config.json" (
:: ============================================================ echo [INFO] config.json will be auto-generated on first launch.
echo Verifying build outputs... ) else (
set "BUILD_OK=1" echo [OK] config.json already exists.
)
if not exist "dist" ( echo.
call :error "dist/ directory missing after build."
set "BUILD_OK=0" :: ============================================================
) :: Done
if not exist "web\dist" ( :: ============================================================
call :error "web\dist/ directory missing after build." call :log "Setup completed successfully at %date% %time%"
set "BUILD_OK=0"
) echo ============================================
echo Setup Complete!
if "!BUILD_OK!"=="0" ( echo ============================================
echo. echo.
echo Build completed but expected output is missing. echo Next steps:
echo Check %LOG_FILE% for details. echo 1. Run: scripts\start.bat
pause echo 2. Open: http://localhost:3000
exit /b 1 echo.
) echo Setup log: %LOG_FILE%
echo [OK] Build outputs verified. echo.
echo. pause
exit /b 0
:: ============================================================
:: Optional: config.json hint :: ============================================================
:: ============================================================ :: Subroutines
if not exist "config.json" ( :: ============================================================
echo [INFO] config.json will be auto-generated on first launch. :step
) else ( echo ---- Step %~1: %~2 ----
echo [OK] config.json already exists. call :log ""
) call :log "---- Step %~1: %~2 ----"
echo. goto :eof
:: ============================================================ :error
:: Done echo.
:: ============================================================ echo [ERROR] %~1
call :log "Setup completed successfully at %date% %time%" call :log "[ERROR] %~1"
goto :eof
echo ============================================
echo Setup Complete! :log
echo ============================================ echo [%time%] %~1 >> "%LOG_FILE%"
echo. goto :eof
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.
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
+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 ""
+43 -46
View File
@@ -1,47 +1,44 @@
@echo off @echo off
title TSMusicBot title TSMusicBot
echo Starting TSMusicBot... echo Starting TSMusicBot...
echo. 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
+47 -24
View File
@@ -22,6 +22,7 @@ export class PlayQueue {
private mode: PlayMode = PlayMode.Sequential; private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>(); private playedIndices = new Set<number>();
private history: number[] = []; private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50; private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void { private pushHistory(idx: number): void {
@@ -100,12 +101,14 @@ export class PlayQueue {
this.currentIndex = -1; this.currentIndex = -1;
this.playedIndices.clear(); this.playedIndices.clear();
this.history = []; this.history = [];
this.forwardStack = [];
} }
play(): QueuedSong | null { play(): QueuedSong | null {
if (this.songs.length === 0) return null; if (this.songs.length === 0) return null;
this.playedIndices.clear(); this.playedIndices.clear();
this.history = []; this.history = [];
this.forwardStack = [];
this.currentIndex = 0; this.currentIndex = 0;
this.playedIndices.add(0); this.playedIndices.add(0);
return this.songs[0]; return this.songs[0];
@@ -118,6 +121,7 @@ export class PlayQueue {
// shuffle from this point. History tracking is independent and // shuffle from this point. History tracking is independent and
// unaffected by this clear. // unaffected by this clear.
this.playedIndices.clear(); this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index; this.currentIndex = index;
this.playedIndices.add(index); this.playedIndices.add(index);
return this.songs[index]; return this.songs[index];
@@ -139,32 +143,45 @@ export class PlayQueue {
this.currentIndex = (this.currentIndex + 1) % this.songs.length; this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex]; return this.songs[this.currentIndex];
} }
case PlayMode.Random: { 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.RandomLoop: { 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.pushHistory(this.currentIndex);
this.currentIndex = 0; this.currentIndex = nextIndex;
return this.songs[0]; 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 { prev(): QueuedSong | null {
if (this.songs.length === 0) return 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 // Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot." // actually played before this one," not "the previous array slot."
while (this.history.length > 0) { while (this.history.length > 0) {
const idx = this.history.pop()!; const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) { if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx; this.currentIndex = idx;
this.playedIndices.add(idx); this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx]; return this.songs[idx];
} }
// Stale entry (song removed) — keep popping. // Stale entry (song removed) — keep popping.
@@ -227,6 +249,7 @@ export class PlayQueue {
this.mode = mode; this.mode = mode;
this.playedIndices.clear(); this.playedIndices.clear();
this.history = []; this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) { if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex); this.playedIndices.add(this.currentIndex);
} }
+21 -2
View File
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
} }
private async cmdAlbum(cmd: ParsedCommand): Promise<string> { 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 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"; if (songs.length === 0) return "Album is empty or not found";
this.queue.clear(); this.queue.clear();
@@ -778,6 +796,7 @@ export class BotInstance extends EventEmitter {
`${p}stop — Stop and clear queue`, `${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`, `${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`, `${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`, `${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`, `${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`, `${p}playlist -q <name or id> — Load playlist from QQ Music`,
+2 -2
View File
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 5, limit: 10,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { 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"; } from "./provider.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search // Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint. // batch). Was broken ca. 2026-05 due to two upstream API changes:
const qqDirectApi = axios.create({ // 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", baseURL: "https://u.y.qq.com",
timeout: 10000, timeout: 10000,
headers: { referer: "https://y.qq.com" }, 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). // Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints. // The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({ const qqFavApi = axios.create({
@@ -82,37 +93,132 @@ export class QQMusicProvider implements MusicProvider {
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20): Promise<SearchResult> {
const reqData = JSON.stringify({ // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
req_0: { // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
module: "music.search.SearchCgiService", // (removed searchid, num_per_page >= 10, corrected search_type values).
method: "DoSearchForQQMusicDesktop", const primary = await this.searchViaMusicuFcg(query, limit);
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, if (primary) return primary;
},
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 ?? [];
const songs: Song[] = list.map((s: any) => ({ // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
id: String(s.mid ?? s.id), // no playlist support). Kept as redundancy.
name: s.title ?? s.name ?? "", 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(" / "), 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, duration: s.interval ?? 0,
coverUrl: s.album?.mid coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg` ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "", : "",
platform: "qq", 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); const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums }; return { songs, playlists: [], albums };
+219 -23
View File
@@ -20,42 +20,89 @@
<div v-if="loading" class="loading">搜索中...</div> <div v-if="loading" class="loading">搜索中...</div>
<template v-else-if="songs.length || albums.length || playlists.length"> <template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<section v-if="albums.length" class="result-section"> <div class="source-bar">
<h2 class="section-title">专辑</h2> <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"> <div class="card-grid">
<router-link <router-link
v-for="al in albums" v-for="al in filteredAlbums"
:key="`${al.platform}-${al.id}`" :key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`" :to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale" class="card hover-scale"
> >
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" /> <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> <div class="card-sub">{{ al.artist }}</div>
</router-link> </router-link>
</div> </div>
</section> </section>
<section v-if="playlists.length" class="result-section"> <section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<h2 class="section-title">歌单</h2>
<div class="card-grid"> <div class="card-grid">
<router-link <router-link
v-for="pl in playlists" v-for="pl in filteredPlaylists"
:key="`${pl.platform}-${pl.id}`" :key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`" :to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale" class="card hover-scale"
> >
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" /> <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> </router-link>
</div> </div>
</section> </section>
<section v-if="songs.length" class="result-section"> <section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<h2 class="section-title">单曲</h2>
<SongCard <SongCard
v-for="(song, i) in songs" v-for="(song, i) in filteredSongs"
:key="`${song.platform}-${song.id}`" :key="`${song.platform}-${song.id}`"
:song="song" :song="song"
:index="i + 1" :index="i + 1"
@@ -72,8 +119,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import axios from 'axios'; import axios from 'axios';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
@@ -83,34 +130,87 @@ import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore(); const store = usePlayerStore();
const route = useRoute(); 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 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 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; } interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const songs = ref<Song[]>([]); const allSongs = ref<Song[]>([]);
const albums = ref<Album[]>([]); const allAlbums = ref<Album[]>([]);
const playlists = ref<Playlist[]>([]); const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false); const loading = ref(false);
const searched = 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() { async function doSearch() {
if (!query.value.trim()) return; if (!query.value.trim()) return;
loading.value = true; loading.value = true;
searched.value = true; searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
try { try {
const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
songs.value = res.data.songs ?? []; allSongs.value = res.data.songs ?? [];
albums.value = res.data.albums ?? []; allAlbums.value = res.data.albums ?? [];
playlists.value = res.data.playlists ?? []; allPlaylists.value = res.data.playlists ?? [];
} catch { } catch {
songs.value = []; albums.value = []; playlists.value = []; allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally { } finally {
loading.value = false; 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(() => { onMounted(() => {
if (query.value) doSearch(); if (query.value) doSearch();
}); });
@@ -180,14 +280,80 @@ onMounted(() => {
gap: 2px; 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 { .result-section {
margin-bottom: 32px; margin-bottom: 32px;
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
} }
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px; gap: 16px 28px;
} }
.card { .card {
display: flex; display: flex;
@@ -198,4 +364,34 @@ onMounted(() => {
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; } .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; } .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> </style>