chore: Phase 1 complete — project scaffold, config, database, logger

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 00:21:55 +08:00
1 parent 7c60bd69ef
commit 9931d8b40d
12 files changed
+6969

No files matched your search

@@ -0,0 +1,88 @@
# TSMusicBot — Master Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build TSMusicBot from scratch — a TeamSpeak music bot supporting NetEase Cloud Music and QQ Music, with a YesPlayMusic-inspired WebUI, TS text commands, and one-click deployment.
**Architecture:** Node.js/TypeScript monolith, single process running TS3 client protocol + audio engine + embedded music APIs + Express/WebSocket web server + Vue.js SPA.
**Tech Stack:** Node.js 20, TypeScript 5, Express, Vue 3/Vite/Pinia, FFmpeg, @discordjs/opus, better-sqlite3, ws
---
## Phases
Each phase produces working, testable software and ends with a commit checkpoint.
- [ ] **[Phase 1: Project Scaffold, Data Layer & Config](2026-03-29-tsmusicbot-phase1.md)** (6 tasks)
- Git init, package.json, tsconfig, dependencies
- JSON config module (load/save/defaults)
- Pino logger
- SQLite database (play_history, bot_instances)
- Application entry point
- Result: `npm run dev` boots and logs "TSMusicBot started"
- [ ] **[Phase 2: TeamSpeak 3 Protocol Layer](2026-03-29-tsmusicbot-phase2.md)** (6 tasks)
- TS3 identity generation (Ed25519)
- Command encoding/decoding (escape/unescape)
- TCP connection (ServerQuery command channel)
- UDP voice connection (Opus packet sending)
- High-level TS3Client (connect, join channel, send text, send voice)
- Result: Can connect to a TS server, join a channel, send/receive messages
- [ ] **[Phase 3: Audio Engine](2026-03-29-tsmusicbot-phase3.md)** (4 tasks)
- Opus encoder wrapper (@discordjs/opus)
- Play queue with 4 modes (sequential, loop, random, random-loop)
- Audio player (FFmpeg → PCM → Opus → 20ms frames)
- Result: Can decode any audio URL and produce timed Opus frames
- [ ] **[Phase 4: Music Source Service](2026-03-29-tsmusicbot-phase4.md)** (6 tasks)
- Embedded API server launcher
- Unified MusicProvider interface
- NetEase Cloud Music adapter (search, playlist, lyrics, auth)
- QQ Music adapter (search, playlist, lyrics, auth)
- Cookie persistence store
- Result: Can search, get song URLs, and authenticate with both platforms
- [ ] **[Phase 5: Bot Core & TS Command System](2026-03-29-tsmusicbot-phase5.md)** (4 tasks)
- Command parser (prefix, aliases, flags, permissions)
- BotInstance (ties TS3Client + AudioPlayer + MusicProvider)
- BotManager (multi-instance lifecycle, persistence)
- Result: Full bot that plays music via TS commands (!play, !next, etc.)
- [ ] **[Phase 6: Web Backend](2026-03-29-tsmusicbot-phase6.md)** (8 tasks)
- Express + WebSocket server bootstrap
- Bot management API (CRUD + start/stop)
- Music search/playlist/lyrics API
- Player control API (play, pause, queue, volume, mode)
- Auth API (QR code, SMS, cookie)
- WebSocket real-time state broadcasting
- Wire everything in index.ts
- Result: Full REST API + WebSocket backend, ready for frontend
- [ ] **[Phase 7: WebUI Frontend](2026-03-29-tsmusicbot-phase7.md)** (10 tasks)
- Vue.js project scaffold (Vite, Pinia, Router)
- SCSS theme (YesPlayMusic dark/light)
- Pinia stores + WebSocket composable
- Router setup
- Navbar (frosted glass)
- Player bar (frosted glass, controls, volume)
- CoverArt component (colored shadow)
- Home page (bot selector, search, playlists, now playing)
- All page views (Search, Playlist, Lyrics, History, Settings)
- Build and verify
- Result: Beautiful, functional WebUI
- [ ] **[Phase 8: Deployment & Packaging](2026-03-29-tsmusicbot-phase8.md)** (5 tasks)
- Windows start script (start.bat)
- Linux one-click install script (install.sh + systemd)
- Docker (Dockerfile + docker-compose.yml)
- Setup Wizard (4-step first-run flow in WebUI)
- Final build and verify
- Result: One-click installable on Windows and Linux
## Total: 8 phases, 49 tasks
## Implementation Order
Phases MUST be implemented in order (1 → 2 → 3 → ... → 8). Each phase depends on the previous one.
@@ -0,0 +1,603 @@
# TSMusicBot Phase 1: Project Scaffold, Data Layer & Config
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Set up the project from scratch with TypeScript, build tooling, data layer (SQLite + JSON config), and logger — producing a runnable `npm start` that boots and logs "TSMusicBot started".
**Architecture:** Monorepo with backend (`src/`) and frontend (`web/`). This phase focuses on backend scaffold only. Express server serves a placeholder page. Data layer uses better-sqlite3 for structured data and JSON files for user-editable config.
**Tech Stack:** Node.js 20, TypeScript 5, Express 4, better-sqlite3, pino, vitest
---
### Task 1: Initialize project and install dependencies
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `.gitignore`
- [ ] **Step 1: Initialize git repo**
```bash
cd "/c/Users/saopig1/Music/teamspeak music bot"
git init
```
- [ ] **Step 2: Create package.json**
```bash
npm init -y
```
Then edit `package.json`:
```json
{
"name": "tsmusicbot",
"version": "0.1.0",
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"license": "MIT"
}
```
- [ ] **Step 3: Install dependencies**
```bash
npm install express ws better-sqlite3 pino
npm install -D typescript tsx vitest @types/node @types/express @types/better-sqlite3 @types/ws
```
- [ ] **Step 4: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "web"]
}
```
- [ ] **Step 5: Create .gitignore**
```
node_modules/
dist/
data/
*.db
.env
.superpowers/
```
- [ ] **Step 6: Commit**
```bash
git add package.json tsconfig.json .gitignore package-lock.json
git commit -m "chore: initialize project with TypeScript and dependencies"
```
---
### Task 2: Config module (JSON-based)
**Files:**
- Create: `src/data/config.ts`
- Create: `src/data/config.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/data/config.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, saveConfig, getDefaultConfig } from './config.js';
const TEST_CONFIG_PATH = path.join(import.meta.dirname, '../../test-config.json');
describe('Config', () => {
afterEach(() => {
if (fs.existsSync(TEST_CONFIG_PATH)) {
fs.unlinkSync(TEST_CONFIG_PATH);
}
});
it('returns default config when file does not exist', () => {
const config = loadConfig(TEST_CONFIG_PATH);
expect(config.webPort).toBe(3000);
expect(config.locale).toBe('zh');
expect(config.theme).toBe('dark');
expect(config.commandPrefix).toBe('!');
});
it('creates config file on save', () => {
const config = getDefaultConfig();
config.webPort = 8080;
saveConfig(TEST_CONFIG_PATH, config);
expect(fs.existsSync(TEST_CONFIG_PATH)).toBe(true);
const loaded = loadConfig(TEST_CONFIG_PATH);
expect(loaded.webPort).toBe(8080);
});
it('merges partial config with defaults', () => {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ webPort: 9000 }));
const config = loadConfig(TEST_CONFIG_PATH);
expect(config.webPort).toBe(9000);
expect(config.locale).toBe('zh'); // default preserved
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/config.test.ts`
Expected: FAIL with "cannot find module './config.js'"
- [ ] **Step 3: Write implementation**
Create `src/data/config.ts`:
```typescript
import fs from 'node:fs';
import path from 'node:path';
export interface BotConfig {
webPort: number;
locale: 'zh' | 'en';
theme: 'dark' | 'light';
commandPrefix: string;
commandAliases: Record<string, string>;
neteaseApiPort: number;
qqMusicApiPort: number;
adminPassword: string;
adminGroups: number[];
autoReturnDelay: number; // seconds, 0 = disabled
autoPauseOnEmpty: boolean;
}
export function getDefaultConfig(): BotConfig {
return {
webPort: 3000,
locale: 'zh',
theme: 'dark',
commandPrefix: '!',
commandAliases: {
p: 'play',
s: 'skip',
n: 'next',
},
neteaseApiPort: 3001,
qqMusicApiPort: 3002,
adminPassword: '',
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
};
}
export function loadConfig(configPath: string): BotConfig {
const defaults = getDefaultConfig();
if (!fs.existsSync(configPath)) {
return defaults;
}
const raw = fs.readFileSync(configPath, 'utf-8');
const partial = JSON.parse(raw) as Partial<BotConfig>;
return { ...defaults, ...partial };
}
export function saveConfig(configPath: string, config: BotConfig): void {
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/config.test.ts`
Expected: 3 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/data/config.ts src/data/config.test.ts
git commit -m "feat: add JSON config module with load/save/defaults"
```
---
### Task 3: Logger setup
**Files:**
- Create: `src/logger.ts`
- [ ] **Step 1: Create logger module**
Create `src/logger.ts`:
```typescript
import pino from 'pino';
import path from 'node:path';
import fs from 'node:fs';
export function createLogger(logDir?: string) {
const targets: pino.TransportTargetOptions[] = [
{
target: 'pino/file',
options: { destination: 1 }, // stdout
level: 'info',
},
];
if (logDir) {
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
targets.push({
target: 'pino/file',
options: { destination: path.join(logDir, 'tsmusicbot.log') },
level: 'debug',
});
}
return pino({
level: 'debug',
transport: { targets },
});
}
export type Logger = pino.Logger;
```
- [ ] **Step 2: Commit**
```bash
git add src/logger.ts
git commit -m "feat: add pino logger with file and stdout transports"
```
---
### Task 4: Database module (SQLite)
**Files:**
- Create: `src/data/database.ts`
- Create: `src/data/database.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/data/database.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import { createDatabase, type Database } from './database.js';
const TEST_DB_PATH = ':memory:';
describe('Database', () => {
let db: Database;
beforeEach(() => {
db = createDatabase(TEST_DB_PATH);
});
afterEach(() => {
db.close();
});
it('creates tables on init', () => {
const tables = db.db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const names = tables.map((t) => t.name);
expect(names).toContain('play_history');
expect(names).toContain('bot_instances');
});
it('records and retrieves play history', () => {
db.addPlayHistory({
botId: 'bot-1',
songId: '12345',
songName: '晴天',
artist: '周杰伦',
album: '叶惠美',
platform: 'netease',
coverUrl: 'https://example.com/cover.jpg',
});
const history = db.getPlayHistory('bot-1', 10);
expect(history).toHaveLength(1);
expect(history[0].songName).toBe('晴天');
expect(history[0].platform).toBe('netease');
});
it('saves and loads bot instances', () => {
db.saveBotInstance({
id: 'bot-1',
name: 'Music Bot',
serverAddress: 'ts.example.com',
serverPort: 9987,
nickname: 'MusicBot',
defaultChannel: '音乐频道',
channelPassword: '',
autoStart: true,
});
const instances = db.getBotInstances();
expect(instances).toHaveLength(1);
expect(instances[0].name).toBe('Music Bot');
expect(instances[0].serverAddress).toBe('ts.example.com');
});
it('deletes bot instance', () => {
db.saveBotInstance({
id: 'bot-1',
name: 'Music Bot',
serverAddress: 'ts.example.com',
serverPort: 9987,
nickname: 'MusicBot',
defaultChannel: '',
channelPassword: '',
autoStart: false,
});
db.deleteBotInstance('bot-1');
expect(db.getBotInstances()).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/database.test.ts`
Expected: FAIL with "cannot find module './database.js'"
- [ ] **Step 3: Write implementation**
Create `src/data/database.ts`:
```typescript
import BetterSqlite3 from 'better-sqlite3';
export interface PlayHistoryEntry {
botId: string;
songId: string;
songName: string;
artist: string;
album: string;
platform: 'netease' | 'qq';
coverUrl: string;
}
export interface PlayHistoryRow extends PlayHistoryEntry {
id: number;
playedAt: string;
}
export interface BotInstanceRow {
id: string;
name: string;
serverAddress: string;
serverPort: number;
nickname: string;
defaultChannel: string;
channelPassword: string;
autoStart: boolean;
}
export interface Database {
db: BetterSqlite3.Database;
close(): void;
addPlayHistory(entry: PlayHistoryEntry): void;
getPlayHistory(botId: string, limit: number): PlayHistoryRow[];
saveBotInstance(instance: BotInstanceRow): void;
getBotInstances(): BotInstanceRow[];
deleteBotInstance(id: string): void;
}
export function createDatabase(dbPath: string): Database {
const db = new BetterSqlite3(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
botId TEXT NOT NULL,
songId TEXT NOT NULL,
songName TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
platform TEXT NOT NULL,
coverUrl TEXT NOT NULL,
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bot_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
serverAddress TEXT NOT NULL,
serverPort INTEGER NOT NULL DEFAULT 9987,
nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL DEFAULT '',
channelPassword TEXT NOT NULL DEFAULT '',
autoStart INTEGER NOT NULL DEFAULT 0
);
`);
const stmts = {
insertHistory: db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
`),
getHistory: db.prepare(`
SELECT * FROM play_history WHERE botId = ? ORDER BY playedAt DESC LIMIT ?
`),
upsertInstance: db.prepare(`
INSERT OR REPLACE INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart)
`),
getInstances: db.prepare(`SELECT * FROM bot_instances`),
deleteInstance: db.prepare(`DELETE FROM bot_instances WHERE id = ?`),
};
return {
db,
close() {
db.close();
},
addPlayHistory(entry: PlayHistoryEntry) {
stmts.insertHistory.run(entry);
},
getPlayHistory(botId: string, limit: number): PlayHistoryRow[] {
return stmts.getHistory.all(botId, limit) as PlayHistoryRow[];
},
saveBotInstance(instance: BotInstanceRow) {
stmts.upsertInstance.run({
...instance,
autoStart: instance.autoStart ? 1 : 0,
});
},
getBotInstances(): BotInstanceRow[] {
const rows = stmts.getInstances.all() as (Omit<BotInstanceRow, 'autoStart'> & { autoStart: number })[];
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 }));
},
deleteBotInstance(id: string) {
stmts.deleteInstance.run(id);
},
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/database.test.ts`
Expected: 4 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/database.test.ts
git commit -m "feat: add SQLite database module with play history and bot instances"
```
---
### Task 5: Application entry point
**Files:**
- Create: `src/index.ts`
- [ ] **Step 1: Create the entry point**
Create `src/index.ts`:
```typescript
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig, saveConfig, getDefaultConfig } from './data/config.js';
import { createDatabase } from './data/database.js';
import { createLogger } from './logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '..');
const DATA_DIR = path.join(ROOT_DIR, 'data');
const CONFIG_PATH = path.join(ROOT_DIR, 'config.json');
const DB_PATH = path.join(DATA_DIR, 'tsmusicbot.db');
const LOG_DIR = path.join(DATA_DIR, 'logs');
async function main() {
// Load config
const config = loadConfig(CONFIG_PATH);
// Save config to ensure file exists with all defaults
saveConfig(CONFIG_PATH, config);
// Create logger
const logger = createLogger(LOG_DIR);
// Create database
const db = createDatabase(DB_PATH);
logger.info({ webPort: config.webPort }, 'TSMusicBot started');
// Graceful shutdown
const shutdown = () => {
logger.info('Shutting down...');
db.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
```
- [ ] **Step 2: Run the application**
Run: `npx tsx src/index.ts`
Expected: Log output containing "TSMusicBot started", then `data/` directory is created with `tsmusicbot.db` and `logs/`, and `config.json` is created at root.
- [ ] **Step 3: Verify files were created**
Run: `ls data/ && cat config.json`
Expected: `tsmusicbot.db`, `logs/` directory exist. `config.json` contains default config with `webPort: 3000`.
- [ ] **Step 4: Commit**
```bash
git add src/index.ts
git commit -m "feat: add application entry point with config, database, and logger initialization"
```
---
### Task 6: Run all tests and verify clean build
- [ ] **Step 1: Run all tests**
Run: `npx vitest run`
Expected: All tests pass (7 tests across 2 files)
- [ ] **Step 2: Verify TypeScript compilation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Final commit for Phase 1**
```bash
git add -A
git commit -m "chore: Phase 1 complete — project scaffold, config, database, logger"
```
@@ -0,0 +1,772 @@
# TSMusicBot Phase 2: TeamSpeak 3 Protocol Layer
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a TS3 client protocol layer that can connect to a TeamSpeak server (without encryption), authenticate, join a channel, send/receive text messages, and send Opus voice data over UDP.
**Architecture:** The protocol layer lives in `src/ts-protocol/`. It implements the TS3 client protocol: TCP for commands (login, channel ops, text messages) and UDP for voice packets. No encryption. The `TS3Client` class provides a high-level EventEmitter-based API for the rest of the application.
**Tech Stack:** Node.js net/dgram modules, tweetnacl (Ed25519 identity), vitest
**Reference:** TeamSpeak 3 protocol documentation and Splamy/TSLib (C#) source code for packet structures and handshake flow.
---
### Task 1: TS3 Identity generation
**Files:**
- Create: `src/ts-protocol/identity.ts`
- Create: `src/ts-protocol/identity.test.ts`
- [ ] **Step 1: Install tweetnacl**
```bash
npm install tweetnacl
npm install -D @types/tweetnacl
```
- [ ] **Step 2: Write the failing test**
Create `src/ts-protocol/identity.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { generateIdentity, exportIdentity, importIdentity } from './identity.js';
describe('TS3 Identity', () => {
it('generates a valid identity with keypair', () => {
const identity = generateIdentity();
expect(identity.publicKey).toBeInstanceOf(Uint8Array);
expect(identity.privateKey).toBeInstanceOf(Uint8Array);
expect(identity.publicKey.length).toBe(32);
expect(identity.privateKey.length).toBe(64);
expect(identity.uid).toBeTruthy();
expect(typeof identity.uid).toBe('string');
});
it('exports and imports identity consistently', () => {
const identity = generateIdentity();
const exported = exportIdentity(identity);
const imported = importIdentity(exported);
expect(imported.uid).toBe(identity.uid);
expect(imported.publicKey).toEqual(identity.publicKey);
});
it('generates unique identities each time', () => {
const id1 = generateIdentity();
const id2 = generateIdentity();
expect(id1.uid).not.toBe(id2.uid);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/ts-protocol/identity.test.ts`
Expected: FAIL with "cannot find module"
- [ ] **Step 4: Write implementation**
Create `src/ts-protocol/identity.ts`:
```typescript
import nacl from 'tweetnacl';
import crypto from 'node:crypto';
export interface TS3Identity {
publicKey: Uint8Array;
privateKey: Uint8Array;
uid: string; // base64-encoded SHA1 of public key
}
export function generateIdentity(): TS3Identity {
const keypair = nacl.sign.keyPair();
const uid = computeUid(keypair.publicKey);
return {
publicKey: keypair.publicKey,
privateKey: keypair.secretKey,
uid,
};
}
export function computeUid(publicKey: Uint8Array): string {
const hash = crypto.createHash('sha1').update(publicKey).digest('base64');
return hash;
}
export function exportIdentity(identity: TS3Identity): string {
return JSON.stringify({
publicKey: Buffer.from(identity.publicKey).toString('base64'),
privateKey: Buffer.from(identity.privateKey).toString('base64'),
});
}
export function importIdentity(data: string): TS3Identity {
const parsed = JSON.parse(data) as { publicKey: string; privateKey: string };
const publicKey = new Uint8Array(Buffer.from(parsed.publicKey, 'base64'));
const privateKey = new Uint8Array(Buffer.from(parsed.privateKey, 'base64'));
const uid = computeUid(publicKey);
return { publicKey, privateKey, uid };
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/ts-protocol/identity.test.ts`
Expected: 3 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/ts-protocol/identity.ts src/ts-protocol/identity.test.ts package.json package-lock.json
git commit -m "feat: add TS3 identity generation with Ed25519 keypairs"
```
---
### Task 2: TS3 Command encoding/decoding
**Files:**
- Create: `src/ts-protocol/commands.ts`
- Create: `src/ts-protocol/commands.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/ts-protocol/commands.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { encodeCommand, decodeResponse, escapeValue, unescapeValue } from './commands.js';
describe('TS3 Commands', () => {
it('encodes a simple command', () => {
const encoded = encodeCommand('login', { client_login_name: 'bot', client_login_password: 'pass' });
expect(encoded).toBe('login client_login_name=bot client_login_password=pass\n');
});
it('escapes special characters in values', () => {
expect(escapeValue('hello world')).toBe('hello\\sworld');
expect(escapeValue('foo|bar')).toBe('foo\\pbar');
expect(escapeValue('a/b')).toBe('a\\/b');
expect(escapeValue('line\nnew')).toBe('line\\nnew');
});
it('unescapes special characters', () => {
expect(unescapeValue('hello\\sworld')).toBe('hello world');
expect(unescapeValue('foo\\pbar')).toBe('foo|bar');
expect(unescapeValue('a\\/b')).toBe('a/b');
});
it('decodes a single response', () => {
const response = 'virtualserver_name=My\\sServer virtualserver_port=9987';
const result = decodeResponse(response);
expect(result).toHaveLength(1);
expect(result[0].virtualserver_name).toBe('My Server');
expect(result[0].virtualserver_port).toBe('9987');
});
it('decodes a piped multi-entry response', () => {
const response = 'clid=1 client_nickname=User1|clid=2 client_nickname=User2';
const result = decodeResponse(response);
expect(result).toHaveLength(2);
expect(result[0].client_nickname).toBe('User1');
expect(result[1].client_nickname).toBe('User2');
});
it('handles command with no params', () => {
const encoded = encodeCommand('quit', {});
expect(encoded).toBe('quit\n');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/ts-protocol/commands.test.ts`
Expected: FAIL
- [ ] **Step 3: Write implementation**
Create `src/ts-protocol/commands.ts`:
```typescript
const ESCAPE_MAP: [string, string][] = [
['\\', '\\\\'],
['/', '\\/'],
[' ', '\\s'],
['|', '\\p'],
['\n', '\\n'],
['\r', '\\r'],
['\t', '\\t'],
['\x07', '\\a'], // bell
['\x08', '\\b'], // backspace
['\x0C', '\\f'], // form feed
['\x0B', '\\v'], // vertical tab
];
const UNESCAPE_MAP: [string, string][] = ESCAPE_MAP.map(([plain, escaped]) => [escaped, plain]).reverse();
export function escapeValue(value: string): string {
let result = value;
for (const [plain, escaped] of ESCAPE_MAP) {
result = result.replaceAll(plain, escaped);
}
return result;
}
export function unescapeValue(value: string): string {
let result = value;
for (const [escaped, plain] of UNESCAPE_MAP) {
result = result.replaceAll(escaped, plain);
}
return result;
}
export function encodeCommand(command: string, params: Record<string, string | number>): string {
const parts = [command];
for (const [key, value] of Object.entries(params)) {
parts.push(`${key}=${escapeValue(String(value))}`);
}
return parts.join(' ') + '\n';
}
export function decodeResponse(raw: string): Record<string, string>[] {
const entries = raw.split('|');
return entries.map((entry) => {
const result: Record<string, string> = {};
const pairs = entry.trim().split(' ');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex === -1) {
result[pair] = '';
} else {
const key = pair.substring(0, eqIndex);
const value = unescapeValue(pair.substring(eqIndex + 1));
result[key] = value;
}
}
return result;
});
}
export interface TS3Response {
errorId: number;
errorMessage: string;
data: Record<string, string>[];
}
export function parseErrorLine(line: string): { id: number; msg: string } {
const decoded = decodeResponse(line.replace(/^error\s+/, ''))[0];
return {
id: parseInt(decoded.id ?? '0', 10),
msg: decoded.msg ?? 'ok',
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/ts-protocol/commands.test.ts`
Expected: 6 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/ts-protocol/commands.ts src/ts-protocol/commands.test.ts
git commit -m "feat: add TS3 command encoding/decoding with escape handling"
```
---
### Task 3: TCP Connection (ServerQuery-style command channel)
**Files:**
- Create: `src/ts-protocol/connection.ts`
- [ ] **Step 1: Write implementation**
Create `src/ts-protocol/connection.ts`:
```typescript
import net from 'node:net';
import { EventEmitter } from 'node:events';
import { encodeCommand, decodeResponse, parseErrorLine } from './commands.js';
export interface ConnectionOptions {
host: string;
port: number; // ServerQuery port, typically 10011
}
export interface CommandResult {
errorId: number;
errorMessage: string;
data: Record<string, string>[];
}
/**
* TCP connection to TeamSpeak 3 server.
* Handles the ServerQuery text protocol for command/control operations.
* Voice data is handled separately via UDP (see voice.ts).
*/
export class TS3Connection extends EventEmitter {
private socket: net.Socket | null = null;
private buffer = '';
private commandQueue: Array<{
resolve: (result: CommandResult) => void;
reject: (err: Error) => void;
}> = [];
private connected = false;
private responseLines: string[] = [];
constructor(private options: ConnectionOptions) {
super();
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.socket = net.createConnection(this.options.port, this.options.host, () => {
this.connected = true;
resolve();
});
this.socket.setEncoding('utf-8');
this.socket.on('data', (data: string) => this.handleData(data));
this.socket.on('error', (err) => {
if (!this.connected) {
reject(err);
}
this.emit('error', err);
});
this.socket.on('close', () => {
this.connected = false;
this.emit('close');
});
});
}
private handleData(data: string): void {
this.buffer += data;
const lines = this.buffer.split('\n\r');
// Keep the incomplete last part in buffer
this.buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Skip welcome messages (TS3 banner)
if (trimmed.startsWith('TS3') || trimmed.startsWith('Welcome')) continue;
// Notifications start with "notify"
if (trimmed.startsWith('notify')) {
this.handleNotification(trimmed);
continue;
}
// Error line completes a command response
if (trimmed.startsWith('error ')) {
const error = parseErrorLine(trimmed);
const pending = this.commandQueue.shift();
if (pending) {
pending.resolve({
errorId: error.id,
errorMessage: error.msg,
data: this.responseLines.length > 0
? decodeResponse(this.responseLines.join('\n'))
: [],
});
}
this.responseLines = [];
continue;
}
// Data line (part of command response)
this.responseLines.push(trimmed);
}
}
private handleNotification(line: string): void {
// Extract notification name: "notifytextmessage" -> "textmessage"
const spaceIndex = line.indexOf(' ');
const notifyPart = spaceIndex === -1 ? line : line.substring(0, spaceIndex);
const eventName = notifyPart.replace(/^notify/, '');
const data = spaceIndex === -1 ? {} : decodeResponse(line.substring(spaceIndex + 1))[0];
this.emit('notify', eventName, data);
this.emit(`notify:${eventName}`, data);
}
async send(command: string, params: Record<string, string | number> = {}): Promise<CommandResult> {
if (!this.socket || !this.connected) {
throw new Error('Not connected');
}
const encoded = encodeCommand(command, params);
return new Promise((resolve, reject) => {
this.commandQueue.push({ resolve, reject });
this.socket!.write(encoded);
});
}
disconnect(): void {
if (this.socket) {
this.socket.destroy();
this.socket = null;
this.connected = false;
}
}
isConnected(): boolean {
return this.connected;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/ts-protocol/connection.ts
git commit -m "feat: add TCP connection with command queue and notification handling"
```
---
### Task 4: UDP Voice channel
**Files:**
- Create: `src/ts-protocol/voice.ts`
- [ ] **Step 1: Write implementation**
Create `src/ts-protocol/voice.ts`:
```typescript
import dgram from 'node:dgram';
import { EventEmitter } from 'node:events';
/**
* TS3 voice packet structure (simplified, no encryption):
*
* Header (variable):
* - packetId: uint16 (2 bytes)
* - clientId: uint16 (2 bytes) — assigned by server after login
* - packetType: uint8 (1 byte) — 0=voice, 1=voice_whisper, etc.
* - flags: uint8 (1 byte)
*
* Payload:
* - codec: uint8 (1 byte) — 4 = Opus Voice, 5 = Opus Music
* - audioData: Opus-encoded frame
*/
export interface VoiceOptions {
host: string;
port: number; // same as virtual server port, typically 9987
}
export const CODEC_OPUS_VOICE = 4;
export const CODEC_OPUS_MUSIC = 5;
export class VoiceConnection extends EventEmitter {
private socket: dgram.Socket | null = null;
private packetCounter = 0;
private clientId = 0;
constructor(private options: VoiceOptions) {
super();
}
setClientId(id: number): void {
this.clientId = id;
}
async connect(): Promise<void> {
return new Promise((resolve) => {
this.socket = dgram.createSocket('udp4');
this.socket.on('message', (msg) => {
this.emit('voiceData', msg);
});
this.socket.on('error', (err) => {
this.emit('error', err);
});
// UDP is connectionless, but we "connect" to set default destination
this.socket.connect(this.options.port, this.options.host, () => {
resolve();
});
});
}
/**
* Send an Opus-encoded audio frame to the TS3 server.
* Frame should be 20ms of Opus-encoded audio (48kHz, mono or stereo).
*/
sendVoicePacket(opusData: Buffer, codec: number = CODEC_OPUS_MUSIC): void {
if (!this.socket) return;
const packetId = this.packetCounter++;
if (this.packetCounter > 0xFFFF) this.packetCounter = 0;
// Build packet header
const header = Buffer.alloc(5);
header.writeUInt16BE(packetId, 0); // packet id
header.writeUInt16BE(this.clientId, 2); // client id
header.writeUInt8(codec, 4); // codec type
const packet = Buffer.concat([header, opusData]);
this.socket.send(packet);
}
/**
* Send a keepalive/ping packet to maintain the UDP connection.
*/
sendKeepAlive(): void {
if (!this.socket) return;
const ping = Buffer.alloc(4);
ping.writeUInt16BE(this.packetCounter++, 0);
ping.writeUInt16BE(this.clientId, 2);
if (this.packetCounter > 0xFFFF) this.packetCounter = 0;
this.socket.send(ping);
}
disconnect(): void {
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/ts-protocol/voice.ts
git commit -m "feat: add UDP voice connection for sending Opus audio packets"
```
---
### Task 5: High-level TS3 Client
**Files:**
- Create: `src/ts-protocol/client.ts`
- [ ] **Step 1: Write implementation**
Create `src/ts-protocol/client.ts`:
```typescript
import { EventEmitter } from 'node:events';
import { TS3Connection, type CommandResult } from './connection.js';
import { VoiceConnection, CODEC_OPUS_MUSIC } from './voice.js';
import { generateIdentity, importIdentity, exportIdentity, type TS3Identity } from './identity.js';
import type { Logger } from '../logger.js';
export interface TS3ClientOptions {
host: string;
port: number; // Voice/virtual server port (default 9987)
queryPort: number; // ServerQuery port (default 10011)
nickname: string;
identity?: string; // Exported identity JSON, or undefined to generate new
defaultChannel?: string;
channelPassword?: string;
}
export interface TS3TextMessage {
invokerName: string;
invokerId: string;
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
}
export class TS3Client extends EventEmitter {
private connection: TS3Connection;
private voice: VoiceConnection;
private identity: TS3Identity;
private clientId = 0;
private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
private logger: Logger;
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
this.logger = logger;
this.connection = new TS3Connection({
host: options.host,
port: options.queryPort,
});
this.voice = new VoiceConnection({
host: options.host,
port: options.port,
});
if (options.identity) {
this.identity = importIdentity(options.identity);
} else {
this.identity = generateIdentity();
}
}
async connect(): Promise<void> {
this.logger.info({ host: this.options.host, port: this.options.port }, 'Connecting to TeamSpeak server');
// Connect TCP (ServerQuery)
await this.connection.connect();
this.logger.debug('TCP connection established');
// Select virtual server by port
await this.sendCommand('use', { port: this.options.port });
// Login with client nickname
const loginResult = await this.sendCommand('clientupdate', {
client_nickname: this.options.nickname,
});
// Get own client ID
const whoami = await this.sendCommand('whoami', {});
if (whoami.data.length > 0) {
this.clientId = parseInt(whoami.data[0].client_id ?? '0', 10);
this.logger.info({ clientId: this.clientId }, 'Logged in');
}
// Join default channel if specified
if (this.options.defaultChannel) {
await this.joinChannel(this.options.defaultChannel, this.options.channelPassword);
}
// Connect UDP voice
await this.voice.connect();
this.voice.setClientId(this.clientId);
this.logger.debug('UDP voice connection established');
// Register for text message notifications
await this.sendCommand('servernotifyregister', { event: 'textchannel' });
await this.sendCommand('servernotifyregister', { event: 'textprivate' });
// Listen for text messages
this.connection.on('notify:textmessage', (data: Record<string, string>) => {
const msg: TS3TextMessage = {
invokerName: data.invokername ?? '',
invokerId: data.invokerid ?? '',
invokerUid: data.invokeruid ?? '',
message: data.msg ?? '',
targetMode: parseInt(data.targetmode ?? '0', 10),
};
this.emit('textMessage', msg);
});
// Start keepalive
this.keepAliveInterval = setInterval(() => {
this.voice.sendKeepAlive();
}, 5000);
this.connection.on('close', () => {
this.logger.warn('Connection closed');
this.emit('disconnected');
});
this.emit('connected');
}
async sendCommand(command: string, params: Record<string, string | number>): Promise<CommandResult> {
return this.connection.send(command, params);
}
async joinChannel(channelName: string, password?: string): Promise<void> {
// Find channel by name
const channels = await this.sendCommand('channellist', {});
const channel = channels.data.find(
(ch) => ch.channel_name === channelName
);
if (!channel) {
this.logger.warn({ channelName }, 'Channel not found');
return;
}
const params: Record<string, string | number> = {
cid: channel.cid,
clid: this.clientId,
};
if (password) {
params.cpw = password;
}
await this.sendCommand('clientmove', params);
this.logger.info({ channelName, cid: channel.cid }, 'Joined channel');
}
async moveToChannel(channelNameOrId: string, password?: string): Promise<void> {
await this.joinChannel(channelNameOrId, password);
}
async sendTextMessage(message: string, targetMode: number = 2): Promise<void> {
// targetMode: 1=private, 2=channel, 3=server
await this.sendCommand('sendtextmessage', {
targetmode: targetMode,
target: targetMode === 2 ? 0 : this.clientId, // 0 = current channel for mode 2
msg: message,
});
}
async getClientsInChannel(): Promise<Record<string, string>[]> {
const result = await this.sendCommand('clientlist', {});
return result.data;
}
sendVoiceData(opusFrame: Buffer): void {
this.voice.sendVoicePacket(opusFrame, CODEC_OPUS_MUSIC);
}
getIdentityExport(): string {
return exportIdentity(this.identity);
}
getClientId(): number {
return this.clientId;
}
disconnect(): void {
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
this.keepAliveInterval = null;
}
this.connection.disconnect();
this.voice.disconnect();
this.logger.info('Disconnected from TeamSpeak server');
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/ts-protocol/client.ts
git commit -m "feat: add high-level TS3Client with connect, channel, text message, and voice API"
```
---
### Task 6: Verify TypeScript compilation
- [ ] **Step 1: Run tsc**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 2: Run all tests**
Run: `npx vitest run`
Expected: All tests pass (identity: 3, commands: 6, config: 3, database: 4 = 16 tests)
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "chore: Phase 2 complete — TS3 protocol layer (identity, commands, TCP, UDP, client)"
```
@@ -0,0 +1,591 @@
# TSMusicBot Phase 3: Audio Engine
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the audio pipeline: FFmpeg decodes audio from a URL → PCM → Opus encoding → 20ms frames ready to send via TS3 voice. Plus a play queue with 4 modes (sequential, loop, random, random-loop).
**Architecture:** `src/audio/encoder.ts` wraps Opus encoding, `src/audio/player.ts` orchestrates FFmpeg → Opus → timed frame emission, `src/audio/queue.ts` manages the song queue with playback modes.
**Tech Stack:** FFmpeg (child_process), @discordjs/opus, vitest
---
### Task 1: Opus encoder wrapper
**Files:**
- Create: `src/audio/encoder.ts`
- Create: `src/audio/encoder.test.ts`
- [ ] **Step 1: Install opus dependency**
```bash
npm install @discordjs/opus
```
- [ ] **Step 2: Write the failing test**
Create `src/audio/encoder.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { createOpusEncoder } from './encoder.js';
describe('OpusEncoder', () => {
it('encodes PCM buffer to Opus frame', () => {
const encoder = createOpusEncoder();
// 20ms of silence at 48kHz stereo = 960 frames * 2 channels * 2 bytes = 3840 bytes
const silence = Buffer.alloc(3840, 0);
const opus = encoder.encode(silence);
expect(opus).toBeInstanceOf(Buffer);
expect(opus.length).toBeGreaterThan(0);
expect(opus.length).toBeLessThan(3840); // compressed should be smaller
});
it('decodes Opus frame back to PCM', () => {
const encoder = createOpusEncoder();
const silence = Buffer.alloc(3840, 0);
const opus = encoder.encode(silence);
const pcm = encoder.decode(opus);
expect(pcm).toBeInstanceOf(Buffer);
expect(pcm.length).toBe(3840);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/audio/encoder.test.ts`
Expected: FAIL
- [ ] **Step 4: Write implementation**
Create `src/audio/encoder.ts`:
```typescript
import { OpusEncoder } from '@discordjs/opus';
const SAMPLE_RATE = 48000;
const CHANNELS = 2;
const FRAME_DURATION_MS = 20;
export const FRAME_SIZE = (SAMPLE_RATE * FRAME_DURATION_MS) / 1000; // 960 samples
export const PCM_FRAME_BYTES = FRAME_SIZE * CHANNELS * 2; // 3840 bytes (16-bit stereo)
export interface Encoder {
encode(pcm: Buffer): Buffer;
decode(opus: Buffer): Buffer;
}
export function createOpusEncoder(): Encoder {
const opus = new OpusEncoder(SAMPLE_RATE, CHANNELS);
return {
encode(pcm: Buffer): Buffer {
return opus.encode(pcm);
},
decode(opusData: Buffer): Buffer {
return opus.decode(opusData);
},
};
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/audio/encoder.test.ts`
Expected: 2 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/audio/encoder.ts src/audio/encoder.test.ts package.json package-lock.json
git commit -m "feat: add Opus encoder/decoder wrapper"
```
---
### Task 2: Play queue with modes
**Files:**
- Create: `src/audio/queue.ts`
- Create: `src/audio/queue.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/audio/queue.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { PlayQueue, type QueuedSong, PlayMode } from './queue.js';
function makeSong(id: string, name: string = id): QueuedSong {
return {
id,
name,
artist: 'Artist',
album: 'Album',
platform: 'netease',
url: `https://example.com/${id}.mp3`,
coverUrl: `https://example.com/${id}.jpg`,
duration: 240,
};
}
describe('PlayQueue', () => {
let queue: PlayQueue;
beforeEach(() => {
queue = new PlayQueue();
});
it('starts empty', () => {
expect(queue.isEmpty()).toBe(true);
expect(queue.current()).toBeNull();
expect(queue.size()).toBe(0);
});
it('adds and retrieves songs', () => {
queue.add(makeSong('1', 'Song A'));
queue.add(makeSong('2', 'Song B'));
expect(queue.size()).toBe(2);
expect(queue.list()[0].name).toBe('Song A');
expect(queue.list()[1].name).toBe('Song B');
});
it('plays first song when starting', () => {
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.play();
expect(queue.current()?.id).toBe('1');
});
it('advances to next song in sequential mode', () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.add(makeSong('3'));
queue.play();
expect(queue.current()?.id).toBe('1');
const next = queue.next();
expect(next?.id).toBe('2');
expect(queue.current()?.id).toBe('2');
});
it('returns null at end in sequential mode', () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong('1'));
queue.play();
const next = queue.next();
expect(next).toBeNull();
});
it('loops in loop mode', () => {
queue.setMode(PlayMode.Loop);
queue.add(makeSong('1'));
queue.play();
const next = queue.next();
expect(next?.id).toBe('1'); // loops back
});
it('goes to previous song', () => {
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.play();
queue.next();
expect(queue.current()?.id).toBe('2');
queue.prev();
expect(queue.current()?.id).toBe('1');
});
it('removes song by index', () => {
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.add(makeSong('3'));
queue.remove(1); // remove index 1 ("2")
expect(queue.size()).toBe(2);
expect(queue.list()[1].id).toBe('3');
});
it('clears all songs', () => {
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.clear();
expect(queue.isEmpty()).toBe(true);
expect(queue.current()).toBeNull();
});
it('random mode returns a song', () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.add(makeSong('3'));
queue.play();
const next = queue.next();
expect(next).not.toBeNull();
});
it('random-loop mode never returns null', () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong('1'));
queue.play();
// Should always return a song
for (let i = 0; i < 10; i++) {
expect(queue.next()).not.toBeNull();
}
});
it('playAt jumps to specific index', () => {
queue.add(makeSong('1'));
queue.add(makeSong('2'));
queue.add(makeSong('3'));
queue.playAt(2);
expect(queue.current()?.id).toBe('3');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/audio/queue.test.ts`
Expected: FAIL
- [ ] **Step 3: Write implementation**
Create `src/audio/queue.ts`:
```typescript
export enum PlayMode {
Sequential = 'seq',
Loop = 'loop',
Random = 'random',
RandomLoop = 'rloop',
}
export interface QueuedSong {
id: string;
name: string;
artist: string;
album: string;
platform: 'netease' | 'qq';
url: string;
coverUrl: string;
duration: number; // seconds
}
export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
add(song: QueuedSong): void {
this.songs.push(song);
}
addMany(songs: QueuedSong[]): void {
this.songs.push(...songs);
}
remove(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
const [removed] = this.songs.splice(index, 1);
// Adjust current index if needed
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
// Current song was removed, keep index but it now points to next song
if (this.currentIndex >= this.songs.length) {
this.currentIndex = this.songs.length - 1;
}
}
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.currentIndex = 0;
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.currentIndex = index;
return this.songs[index];
}
next(): QueuedSong | null {
if (this.songs.length === 0) return null;
switch (this.mode) {
case PlayMode.Sequential: {
const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.songs.length) return null;
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Loop: {
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0];
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.songs.length);
} while (nextIndex === this.currentIndex && this.songs.length > 1);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
// Same as Random but never returns null
if (this.songs.length === 1) {
this.currentIndex = 0;
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
}
}
}
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
this.currentIndex = this.songs.length - 1; // wrap to end
} else {
this.currentIndex = prevIndex;
}
return this.songs[this.currentIndex];
}
current(): QueuedSong | null {
if (this.currentIndex < 0 || this.currentIndex >= this.songs.length) return null;
return this.songs[this.currentIndex];
}
list(): QueuedSong[] {
return [...this.songs];
}
size(): number {
return this.songs.length;
}
isEmpty(): boolean {
return this.songs.length === 0;
}
getMode(): PlayMode {
return this.mode;
}
setMode(mode: PlayMode): void {
this.mode = mode;
}
getCurrentIndex(): number {
return this.currentIndex;
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/audio/queue.test.ts`
Expected: 12 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/audio/queue.ts src/audio/queue.test.ts
git commit -m "feat: add play queue with sequential, loop, random, and random-loop modes"
```
---
### Task 3: Audio player (FFmpeg → Opus → timed frames)
**Files:**
- Create: `src/audio/player.ts`
- [ ] **Step 1: Write implementation**
Create `src/audio/player.ts`:
```typescript
import { spawn, type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from './encoder.js';
import type { Logger } from '../logger.js';
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
error: (err: Error) => void;
}
export type PlayerState = 'idle' | 'playing' | 'paused';
export class AudioPlayer extends EventEmitter {
private ffmpeg: ChildProcess | null = null;
private encoder: Encoder;
private state: PlayerState = 'idle';
private volume = 75; // 0-100
private frameTimer: ReturnType<typeof setInterval> | null = null;
private pcmBuffer: Buffer = Buffer.alloc(0);
private logger: Logger;
constructor(logger: Logger) {
super();
this.encoder = createOpusEncoder();
this.logger = logger;
}
/**
* Start playing audio from a URL or file path.
* FFmpeg decodes to raw PCM (48kHz, stereo, s16le).
*/
play(url: string): void {
this.stop();
this.logger.info({ url }, 'Starting playback');
this.ffmpeg = spawn('ffmpeg', [
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-i', url,
'-f', 's16le', // raw PCM output
'-ar', '48000', // 48kHz sample rate
'-ac', '2', // stereo
'-af', `volume=${this.volume / 100}`,
'-', // output to stdout
], {
stdio: ['ignore', 'pipe', 'ignore'],
});
this.ffmpeg.stdout!.on('data', (chunk: Buffer) => {
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
});
this.ffmpeg.on('close', (code) => {
this.logger.debug({ code }, 'FFmpeg process closed');
if (this.state === 'playing') {
// Flush remaining frames
this.flushRemainingFrames();
this.state = 'idle';
this.emit('trackEnd');
}
});
this.ffmpeg.on('error', (err) => {
this.logger.error({ err }, 'FFmpeg error');
this.emit('error', err);
});
this.state = 'playing';
// Send frames every 20ms
this.frameTimer = setInterval(() => {
if (this.state !== 'playing') return;
this.sendNextFrame();
}, 20);
}
private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
const opusFrame = this.encoder.encode(Buffer.from(pcmFrame));
this.emit('frame', opusFrame);
}
private flushRemainingFrames(): void {
while (this.pcmBuffer.length >= PCM_FRAME_BYTES) {
this.sendNextFrame();
}
}
pause(): void {
if (this.state === 'playing') {
this.state = 'paused';
this.logger.debug('Playback paused');
}
}
resume(): void {
if (this.state === 'paused') {
this.state = 'playing';
this.logger.debug('Playback resumed');
}
}
stop(): void {
if (this.frameTimer) {
clearInterval(this.frameTimer);
this.frameTimer = null;
}
if (this.ffmpeg) {
this.ffmpeg.kill('SIGTERM');
this.ffmpeg = null;
}
this.pcmBuffer = Buffer.alloc(0);
this.state = 'idle';
}
setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol));
// Volume change takes effect on next track.
// For live volume change, we'd need to restart FFmpeg or use a PCM gain stage.
// TODO: implement PCM-level volume adjustment for live changes
}
getVolume(): number {
return this.volume;
}
getState(): PlayerState {
return this.state;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/audio/player.ts
git commit -m "feat: add audio player with FFmpeg decode, Opus encode, and 20ms frame timing"
```
---
### Task 4: Verify compilation and all tests
- [ ] **Step 1: Run all tests**
Run: `npx vitest run`
Expected: All tests pass (encoder: 2, queue: 12, identity: 3, commands: 6, config: 3, database: 4 = 30 tests)
- [ ] **Step 2: Verify TypeScript compilation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "chore: Phase 3 complete — audio engine (Opus encoder, play queue, FFmpeg player)"
```
@@ -0,0 +1,812 @@
# TSMusicBot Phase 4: Music Source Service
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Integrate NetEase Cloud Music and QQ Music APIs as embedded services, wrapped behind a unified music provider interface. Support search, song URL retrieval, playlists, albums, lyrics, and authentication (QR code, SMS, Cookie).
**Architecture:** `src/music/api-server.ts` manages spawning the two API server processes. `src/music/provider.ts` defines the unified interface. `src/music/netease.ts` and `src/music/qq.ts` implement it for each platform. `src/music/auth.ts` handles login flows.
**Tech Stack:** NeteaseCloudMusicApi (npm), QQMusicApi (npm or git), axios, vitest
---
### Task 1: Embedded API server launcher
**Files:**
- Create: `src/music/api-server.ts`
- [ ] **Step 1: Install music API dependencies**
```bash
npm install NeteaseCloudMusicApi axios
```
Note: QQ Music API may need to be installed from a git repository. Check the latest source:
```bash
npm install qq-music-api || echo "Will implement QQ Music API wrapper manually if no npm package exists"
```
- [ ] **Step 2: Write implementation**
Create `src/music/api-server.ts`:
```typescript
import { fork, type ChildProcess } from 'node:child_process';
import type { Logger } from '../logger.js';
export interface ApiServerOptions {
neteasePort: number;
qqMusicPort: number;
}
export interface ApiServerManager {
start(): Promise<void>;
stop(): void;
getNeteaseBaseUrl(): string;
getQQMusicBaseUrl(): string;
}
export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
let neteaseReady = false;
let qqMusicReady = false;
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
return {
async start(): Promise<void> {
logger.info('Starting embedded music API servers...');
// Start NetEase Cloud Music API
try {
// NeteaseCloudMusicApi can be started programmatically
const { default: ncmApi } = await import('NeteaseCloudMusicApi');
// The API exposes a start function or we use it as an Express middleware
// Depending on the version, we use the appropriate start method
logger.info({ port: options.neteasePort }, 'NetEase Cloud Music API starting');
neteaseReady = true;
} catch (err) {
logger.error({ err }, 'Failed to start NetEase Cloud Music API');
}
// Start QQ Music API (if available)
try {
logger.info({ port: options.qqMusicPort }, 'QQ Music API starting');
qqMusicReady = true;
} catch (err) {
logger.warn({ err }, 'QQ Music API not available, QQ Music features will be disabled');
}
},
stop(): void {
logger.info('Stopping music API servers');
neteaseReady = false;
qqMusicReady = false;
},
getNeteaseBaseUrl(): string {
return neteaseBaseUrl;
},
getQQMusicBaseUrl(): string {
return qqMusicBaseUrl;
},
};
}
```
- [ ] **Step 3: Commit**
```bash
git add src/music/api-server.ts package.json package-lock.json
git commit -m "feat: add embedded music API server manager"
```
---
### Task 2: Unified music provider interface
**Files:**
- Create: `src/music/provider.ts`
- [ ] **Step 1: Write implementation**
Create `src/music/provider.ts`:
```typescript
export interface Song {
id: string;
name: string;
artist: string;
album: string;
duration: number; // seconds
coverUrl: string;
platform: 'netease' | 'qq';
}
export interface SongWithUrl extends Song {
url: string; // playable audio URL
}
export interface Playlist {
id: string;
name: string;
coverUrl: string;
songCount: number;
platform: 'netease' | 'qq';
}
export interface Album {
id: string;
name: string;
artist: string;
coverUrl: string;
songCount: number;
platform: 'netease' | 'qq';
}
export interface LyricLine {
time: number; // seconds
text: string;
translation?: string;
}
export interface SearchResult {
songs: Song[];
playlists: Playlist[];
albums: Album[];
}
export interface QrCodeResult {
qrUrl: string; // URL to encode as QR
key: string; // key to poll status
}
export interface AuthStatus {
loggedIn: boolean;
nickname?: string;
avatarUrl?: string;
}
export interface MusicProvider {
readonly platform: 'netease' | 'qq';
// Search
search(query: string, limit?: number): Promise<SearchResult>;
// Song operations
getSongUrl(songId: string): Promise<string | null>;
getSongDetail(songId: string): Promise<Song | null>;
// Playlist operations
getPlaylistSongs(playlistId: string): Promise<Song[]>;
getRecommendPlaylists(): Promise<Playlist[]>;
// Album operations
getAlbumSongs(albumId: string): Promise<Song[]>;
// Lyrics
getLyrics(songId: string): Promise<LyricLine[]>;
// Authentication
getQrCode(): Promise<QrCodeResult>;
checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'>;
loginWithSms?(phone: string, code: string): Promise<boolean>;
sendSmsCode?(phone: string): Promise<boolean>;
setCookie(cookie: string): void;
getCookie(): string;
getAuthStatus(): Promise<AuthStatus>;
// Personal FM (netease only)
getPersonalFm?(): Promise<Song[]>;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/music/provider.ts
git commit -m "feat: add unified MusicProvider interface with search, playlist, auth, and lyrics"
```
---
### Task 3: NetEase Cloud Music adapter
**Files:**
- Create: `src/music/netease.ts`
- Create: `src/music/netease.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/music/netease.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { parseLyrics } from './netease.js';
describe('NetEase adapter', () => {
it('parses LRC format lyrics', () => {
const lrc = `[00:00.00] 作词 : 周杰伦
[00:01.00] 作曲 : 周杰伦
[00:12.50]故事的小黄花
[00:15.80]从出生那年就飘着`;
const lines = parseLyrics(lrc);
expect(lines).toHaveLength(2); // skip metadata lines
expect(lines[0].time).toBeCloseTo(12.5, 1);
expect(lines[0].text).toBe('故事的小黄花');
expect(lines[1].time).toBeCloseTo(15.8, 1);
expect(lines[1].text).toBe('从出生那年就飘着');
});
it('handles empty lyrics', () => {
const lines = parseLyrics('');
expect(lines).toHaveLength(0);
});
it('merges translation lyrics', () => {
const lrc = '[00:12.50]Hello world';
const tlyric = '[00:12.50]你好世界';
const lines = parseLyrics(lrc, tlyric);
expect(lines[0].text).toBe('Hello world');
expect(lines[0].translation).toBe('你好世界');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/music/netease.test.ts`
Expected: FAIL
- [ ] **Step 3: Write implementation**
Create `src/music/netease.ts`:
```typescript
import axios, { type AxiosInstance } from 'axios';
import type {
MusicProvider, Song, SongWithUrl, Playlist, Album,
LyricLine, SearchResult, QrCodeResult, AuthStatus,
} from './provider.js';
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
if (!lrc) return [];
const parseLine = (line: string): { time: number; text: string } | null => {
const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)$/);
if (!match) return null;
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const ms = parseInt(match[3].padEnd(3, '0'), 10);
const text = match[4].trim();
// Skip metadata lines (作词, 作曲, 编曲, etc.)
if (/^(作词|作曲|编曲|制作|混音|母带)\s*[::]/.test(text)) return null;
return { time: minutes * 60 + seconds + ms / 1000, text };
};
const lines: LyricLine[] = [];
const translationMap = new Map<number, string>();
// Parse translation lyrics first
if (tlyric) {
for (const line of tlyric.split('\n')) {
const parsed = parseLine(line);
if (parsed) {
translationMap.set(Math.round(parsed.time * 100), parsed.text);
}
}
}
for (const line of lrc.split('\n')) {
const parsed = parseLine(line);
if (parsed) {
const timeKey = Math.round(parsed.time * 100);
lines.push({
time: parsed.time,
text: parsed.text,
translation: translationMap.get(timeKey),
});
}
}
return lines.sort((a, b) => a.time - b.time);
}
export class NeteaseProvider implements MusicProvider {
readonly platform = 'netease' as const;
private api: AxiosInstance;
private cookie = '';
constructor(baseUrl: string) {
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
});
}
private get cookieParams(): Record<string, string> {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
this.api.get('/cloudsearch', {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
this.api.get('/cloudsearch', {
params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams },
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
album: s.al?.name ?? '',
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? '',
platform: 'netease',
}));
const playlists: Playlist[] = (playlistRes.data?.result?.playlists ?? []).map((p: any) => ({
id: String(p.id),
name: p.name,
coverUrl: p.coverImgUrl ?? '',
songCount: p.trackCount ?? 0,
platform: 'netease',
}));
return { songs, playlists, albums: [] };
}
async getSongUrl(songId: string): Promise<string | null> {
const res = await this.api.get('/song/url/v1', {
params: { id: songId, level: 'exhigh', ...this.cookieParams },
});
const url = res.data?.data?.[0]?.url;
return url ?? null;
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get('/song/detail', {
params: { ids: songId, ...this.cookieParams },
});
const s = res.data?.songs?.[0];
if (!s) return null;
return {
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
album: s.al?.name ?? '',
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? '',
platform: 'netease',
};
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get('/playlist/track/all', {
params: { id: playlistId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
album: s.al?.name ?? '',
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? '',
platform: 'netease',
}));
}
async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get('/personalized', {
params: { limit: 10, ...this.cookieParams },
});
return (res.data?.result ?? []).map((p: any) => ({
id: String(p.id),
name: p.name,
coverUrl: p.picUrl ?? '',
songCount: p.trackCount ?? 0,
platform: 'netease',
}));
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.api.get('/album', {
params: { id: albumId, ...this.cookieParams },
});
return (res.data?.songs ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
album: s.al?.name ?? '',
duration: Math.round((s.dt ?? 0) / 1000),
coverUrl: s.al?.picUrl ?? '',
platform: 'netease',
}));
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get('/lyric', {
params: { id: songId, ...this.cookieParams },
});
return parseLyrics(res.data?.lrc?.lyric ?? '', res.data?.tlyric?.lyric);
}
async getQrCode(): Promise<QrCodeResult> {
const keyRes = await this.api.get('/login/qr/key');
const key = keyRes.data?.data?.unikey ?? '';
const createRes = await this.api.get('/login/qr/create', {
params: { key, qrimg: true },
});
return {
qrUrl: createRes.data?.data?.qrurl ?? '',
key,
};
}
async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
const res = await this.api.get('/login/qr/check', {
params: { key },
});
const code = res.data?.code;
switch (code) {
case 801: return 'waiting';
case 802: return 'scanned';
case 803:
// Save cookie from response
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return 'confirmed';
default: return 'expired';
}
}
async sendSmsCode(phone: string): Promise<boolean> {
const res = await this.api.get('/captcha/sent', {
params: { phone },
});
return res.data?.code === 200;
}
async loginWithSms(phone: string, code: string): Promise<boolean> {
const res = await this.api.get('/captcha/verify', {
params: { phone, captcha: code },
});
if (res.data?.cookie) {
this.cookie = res.data.cookie;
}
return res.data?.code === 200;
}
setCookie(cookie: string): void {
this.cookie = cookie;
}
getCookie(): string {
return this.cookie;
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
try {
const res = await this.api.get('/login/status', {
params: { ...this.cookieParams },
});
const profile = res.data?.data?.profile;
if (profile) {
return {
loggedIn: true,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
};
}
} catch {
// ignore
}
return { loggedIn: false };
}
async getPersonalFm(): Promise<Song[]> {
const res = await this.api.get('/personal_fm', {
params: { ...this.cookieParams },
});
return (res.data?.data ?? []).map((s: any) => ({
id: String(s.id),
name: s.name,
artist: (s.artists ?? []).map((a: any) => a.name).join(' / '),
album: s.album?.name ?? '',
duration: Math.round((s.duration ?? 0) / 1000),
coverUrl: s.album?.picUrl ?? '',
platform: 'netease',
}));
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/music/netease.test.ts`
Expected: 3 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/music/netease.ts src/music/netease.test.ts
git commit -m "feat: add NetEase Cloud Music provider with search, playlist, lyrics, and auth"
```
---
### Task 4: QQ Music adapter
**Files:**
- Create: `src/music/qq.ts`
- [ ] **Step 1: Write implementation**
Create `src/music/qq.ts`:
```typescript
import axios, { type AxiosInstance } from 'axios';
import type {
MusicProvider, Song, Playlist, Album,
LyricLine, SearchResult, QrCodeResult, AuthStatus,
} from './provider.js';
import { parseLyrics } from './netease.js';
export class QQMusicProvider implements MusicProvider {
readonly platform = 'qq' as const;
private api: AxiosInstance;
private cookie = '';
constructor(baseUrl: string) {
this.api = axios.create({
baseURL: baseUrl,
timeout: 10000,
});
}
private get cookieParams(): Record<string, string> {
return this.cookie ? { cookie: this.cookie } : {};
}
async search(query: string, limit = 20): Promise<SearchResult> {
const res = await this.api.get('/search', {
params: { key: query, pageSize: limit, ...this.cookieParams },
});
const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
name: s.songname ?? s.name ?? '',
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
album: s.albumname ?? '',
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: '',
platform: 'qq',
}));
return { songs, playlists: [], albums: [] };
}
async getSongUrl(songId: string): Promise<string | null> {
const res = await this.api.get('/song/url', {
params: { id: songId, ...this.cookieParams },
});
return res.data?.data ?? null;
}
async getSongDetail(songId: string): Promise<Song | null> {
const res = await this.api.get('/song', {
params: { songmid: songId, ...this.cookieParams },
});
const s = res.data?.data;
if (!s) return null;
return {
id: String(s.mid ?? s.id),
name: s.name ?? '',
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
album: s.album?.name ?? '',
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: '',
platform: 'qq',
};
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
const res = await this.api.get('/songlist', {
params: { id: playlistId, ...this.cookieParams },
});
return (res.data?.data?.songlist ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
name: s.songname ?? s.name ?? '',
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
album: s.albumname ?? '',
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: '',
platform: 'qq',
}));
}
async getRecommendPlaylists(): Promise<Playlist[]> {
const res = await this.api.get('/recommend/playlist', {
params: { ...this.cookieParams },
});
return (res.data?.data?.list ?? []).map((p: any) => ({
id: String(p.content_id ?? p.id),
name: p.title ?? p.name ?? '',
coverUrl: p.cover ?? '',
songCount: p.cnt ?? 0,
platform: 'qq',
}));
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
const res = await this.api.get('/album/songs', {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.id),
name: s.songname ?? s.name ?? '',
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
album: s.albumname ?? '',
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: '',
platform: 'qq',
}));
}
async getLyrics(songId: string): Promise<LyricLine[]> {
const res = await this.api.get('/lyric', {
params: { songmid: songId, ...this.cookieParams },
});
return parseLyrics(res.data?.data?.lyric ?? '', res.data?.data?.trans ?? '');
}
async getQrCode(): Promise<QrCodeResult> {
const res = await this.api.get('/login/qr/create');
return {
qrUrl: res.data?.data?.qrurl ?? '',
key: res.data?.data?.key ?? '',
};
}
async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
const res = await this.api.get('/login/qr/check', {
params: { key },
});
const code = res.data?.data?.code;
if (code === 0) {
if (res.data?.data?.cookie) {
this.cookie = res.data.data.cookie;
}
return 'confirmed';
}
if (code === 1) return 'scanned';
if (code === 2) return 'waiting';
return 'expired';
}
setCookie(cookie: string): void {
this.cookie = cookie;
}
getCookie(): string {
return this.cookie;
}
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
try {
const res = await this.api.get('/user/detail', {
params: { ...this.cookieParams },
});
if (res.data?.data) {
return {
loggedIn: true,
nickname: res.data.data.nickname,
avatarUrl: res.data.data.headpic,
};
}
} catch {
// ignore
}
return { loggedIn: false };
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/music/qq.ts
git commit -m "feat: add QQ Music provider with search, playlist, lyrics, and auth"
```
---
### Task 5: Auth module (Cookie persistence)
**Files:**
- Create: `src/music/auth.ts`
- [ ] **Step 1: Write implementation**
Create `src/music/auth.ts`:
```typescript
import fs from 'node:fs';
import path from 'node:path';
export interface CookieStore {
save(platform: 'netease' | 'qq', cookie: string): void;
load(platform: 'netease' | 'qq'): string;
}
export function createCookieStore(cookieDir: string): CookieStore {
if (!fs.existsSync(cookieDir)) {
fs.mkdirSync(cookieDir, { recursive: true });
}
return {
save(platform: 'netease' | 'qq', cookie: string): void {
const filePath = path.join(cookieDir, `${platform}.json`);
fs.writeFileSync(filePath, JSON.stringify({ cookie, updatedAt: new Date().toISOString() }), 'utf-8');
},
load(platform: 'netease' | 'qq'): string {
const filePath = path.join(cookieDir, `${platform}.json`);
if (!fs.existsSync(filePath)) return '';
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
return data.cookie ?? '';
} catch {
return '';
}
},
};
}
```
- [ ] **Step 2: Commit**
```bash
git add src/music/auth.ts
git commit -m "feat: add cookie persistence store for music platform authentication"
```
---
### Task 6: Verify and commit Phase 4
- [ ] **Step 1: Run all tests**
Run: `npx vitest run`
Expected: All tests pass
- [ ] **Step 2: Verify TypeScript compilation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "chore: Phase 4 complete — music source service (NetEase, QQ Music, auth)"
```
@@ -0,0 +1,869 @@
# TSMusicBot Phase 5: Bot Core & TS Command System
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build BotInstance (ties TS3Client + AudioPlayer + MusicProvider together), BotManager (multi-instance lifecycle), and the TS text command parser/executor with permissions.
**Architecture:** `BotInstance` is the core unit — one TS connection, one player, one queue. `BotManager` creates/destroys instances and routes commands. `CommandHandler` parses `!play`, `!next`, etc. from TS text messages and dispatches them.
**Tech Stack:** TypeScript, vitest
---
### Task 1: Command parser
**Files:**
- Create: `src/bot/commands.ts`
- Create: `src/bot/commands.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/bot/commands.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { parseCommand, type ParsedCommand } from './commands.js';
describe('Command Parser', () => {
it('parses simple command', () => {
const result = parseCommand('!play 晴天', '!');
expect(result).not.toBeNull();
expect(result!.name).toBe('play');
expect(result!.args).toBe('晴天');
expect(result!.rawArgs).toEqual(['晴天']);
});
it('parses command with flags', () => {
const result = parseCommand('!play -q 七里香', '!');
expect(result!.name).toBe('play');
expect(result!.flags.has('q')).toBe(true);
expect(result!.args).toBe('七里香');
});
it('returns null for non-command messages', () => {
expect(parseCommand('hello world', '!')).toBeNull();
expect(parseCommand('', '!')).toBeNull();
});
it('handles custom prefix', () => {
const result = parseCommand('/play test', '/');
expect(result!.name).toBe('play');
expect(result!.args).toBe('test');
});
it('resolves aliases', () => {
const aliases = { p: 'play', s: 'skip', n: 'next' };
const result = parseCommand('!p 稻香', '!', aliases);
expect(result!.name).toBe('play');
expect(result!.args).toBe('稻香');
});
it('parses command with no args', () => {
const result = parseCommand('!pause', '!');
expect(result!.name).toBe('pause');
expect(result!.args).toBe('');
expect(result!.rawArgs).toEqual([]);
});
it('parses vol command with number arg', () => {
const result = parseCommand('!vol 80', '!');
expect(result!.name).toBe('vol');
expect(result!.args).toBe('80');
});
it('parses mode command', () => {
const result = parseCommand('!mode loop', '!');
expect(result!.name).toBe('mode');
expect(result!.args).toBe('loop');
});
it('parses remove command with index', () => {
const result = parseCommand('!remove 3', '!');
expect(result!.name).toBe('remove');
expect(result!.args).toBe('3');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/bot/commands.test.ts`
Expected: FAIL
- [ ] **Step 3: Write implementation**
Create `src/bot/commands.ts`:
```typescript
export interface ParsedCommand {
name: string; // command name (e.g., "play")
args: string; // everything after the command name and flags
rawArgs: string[]; // split args
flags: Set<string>; // single-char flags (e.g., -q)
}
// Commands anyone can use
export const PUBLIC_COMMANDS = new Set([
'play', 'add', 'queue', 'list', 'now', 'lyrics', 'vote', 'help',
'playlist', 'album', 'fm', 'prev', 'next', 'skip', 'pause', 'resume',
]);
// Commands that require admin
export const ADMIN_COMMANDS = new Set([
'stop', 'clear', 'move', 'vol', 'mode', 'follow', 'remove',
]);
export function parseCommand(
message: string,
prefix: string,
aliases: Record<string, string> = {},
): ParsedCommand | null {
const trimmed = message.trim();
if (!trimmed.startsWith(prefix)) return null;
const withoutPrefix = trimmed.slice(prefix.length);
if (!withoutPrefix) return null;
const parts = withoutPrefix.split(/\s+/);
let name = parts[0].toLowerCase();
// Resolve alias
if (aliases[name]) {
name = aliases[name];
}
// Parse flags and remaining args
const flags = new Set<string>();
const argParts: string[] = [];
for (let i = 1; i < parts.length; i++) {
if (parts[i].startsWith('-') && parts[i].length === 2 && /[a-zA-Z]/.test(parts[i][1])) {
flags.add(parts[i][1].toLowerCase());
} else {
argParts.push(parts[i]);
}
}
return {
name,
args: argParts.join(' '),
rawArgs: argParts,
flags,
};
}
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/bot/commands.test.ts`
Expected: 9 tests PASS
- [ ] **Step 5: Commit**
```bash
git add src/bot/commands.ts src/bot/commands.test.ts
git commit -m "feat: add TS command parser with aliases, flags, and permission classification"
```
---
### Task 2: BotInstance
**Files:**
- Create: `src/bot/instance.ts`
- [ ] **Step 1: Write implementation**
Create `src/bot/instance.ts`:
```typescript
import { EventEmitter } from 'node:events';
import { TS3Client, type TS3ClientOptions, type TS3TextMessage } from '../ts-protocol/client.js';
import { AudioPlayer } from '../audio/player.js';
import { PlayQueue, PlayMode, type QueuedSong } from '../audio/queue.js';
import type { MusicProvider, Song } from '../music/provider.js';
import { parseCommand, isAdminCommand, type ParsedCommand } from './commands.js';
import type { Logger } from '../logger.js';
import type { Database } from '../data/database.js';
import type { BotConfig } from '../data/config.js';
export interface BotInstanceOptions {
id: string;
name: string;
tsOptions: TS3ClientOptions;
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
database: Database;
config: BotConfig;
logger: Logger;
}
export interface BotStatus {
id: string;
name: string;
connected: boolean;
playing: boolean;
paused: boolean;
currentSong: QueuedSong | null;
queueSize: number;
volume: number;
playMode: PlayMode;
}
export class BotInstance extends EventEmitter {
readonly id: string;
readonly name: string;
private tsClient: TS3Client;
private player: AudioPlayer;
private queue: PlayQueue;
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private database: Database;
private config: BotConfig;
private logger: Logger;
private connected = false;
private voteSkipUsers = new Set<string>();
constructor(options: BotInstanceOptions) {
super();
this.id = options.id;
this.name = options.name;
this.neteaseProvider = options.neteaseProvider;
this.qqProvider = options.qqProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
this.setupPlayerEvents();
this.setupTsEvents();
}
private setupPlayerEvents(): void {
this.player.on('frame', (opusFrame: Buffer) => {
this.tsClient.sendVoiceData(opusFrame);
});
this.player.on('trackEnd', () => {
this.logger.debug('Track ended, advancing queue');
this.playNext();
});
this.player.on('error', (err: Error) => {
this.logger.error({ err }, 'Player error');
this.playNext();
});
}
private setupTsEvents(): void {
this.tsClient.on('textMessage', (msg: TS3TextMessage) => {
this.handleTextMessage(msg);
});
this.tsClient.on('disconnected', () => {
this.connected = false;
this.player.stop();
this.emit('disconnected');
});
}
async connect(): Promise<void> {
await this.tsClient.connect();
this.connected = true;
this.emit('connected');
}
disconnect(): void {
this.player.stop();
this.tsClient.disconnect();
this.connected = false;
this.emit('disconnected');
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(msg.message, this.config.commandPrefix, this.config.commandAliases);
if (!parsed) return;
// Permission check
if (isAdminCommand(parsed.name)) {
// For now, allow all commands. Admin check via TS Server Groups
// will be implemented when we add the permission system.
// TODO: Check if invoker is in adminGroups
}
this.logger.info({ command: parsed.name, args: parsed.args, invoker: msg.invokerName }, 'Command received');
try {
const response = await this.executeCommand(parsed, msg);
if (response) {
await this.tsClient.sendTextMessage(response);
}
} catch (err) {
this.logger.error({ err, command: parsed.name }, 'Command execution error');
await this.tsClient.sendTextMessage(`Error: ${(err as Error).message}`);
}
}
async executeCommand(cmd: ParsedCommand, msg?: TS3TextMessage): Promise<string | null> {
switch (cmd.name) {
case 'play': return this.cmdPlay(cmd);
case 'add': return this.cmdAdd(cmd);
case 'pause': return this.cmdPause();
case 'resume': return this.cmdResume();
case 'stop': return this.cmdStop();
case 'next':
case 'skip': return this.cmdNext();
case 'prev': return this.cmdPrev();
case 'vol': return this.cmdVol(cmd);
case 'now': return this.cmdNow();
case 'queue':
case 'list': return this.cmdQueue();
case 'clear': return this.cmdClear();
case 'remove': return this.cmdRemove(cmd);
case 'mode': return this.cmdMode(cmd);
case 'playlist': return this.cmdPlaylist(cmd);
case 'album': return this.cmdAlbum(cmd);
case 'fm': return this.cmdFm();
case 'vote': return this.cmdVote(msg);
case 'lyrics': return this.cmdLyrics();
case 'move': return this.cmdMove(cmd);
case 'follow': return this.cmdFollow(msg);
case 'help': return this.cmdHelp();
default: return `Unknown command: ${cmd.name}. Type ${this.config.commandPrefix}help for help.`;
}
}
private getProvider(useQQ: boolean): MusicProvider {
return useQQ ? this.qqProvider : this.neteaseProvider;
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return 'Usage: !play <song name or URL>';
const provider = this.getProvider(cmd.flags.has('q'));
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
const song = result.songs[0];
const url = await provider.getSongUrl(song.id);
if (!url) return `Cannot get play URL for: ${song.name}`;
const queuedSong: QueuedSong = { ...song, url, platform: provider.platform };
this.queue.clear();
this.queue.add(queuedSong);
this.queue.play();
this.player.play(url);
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
songName: song.name,
artist: song.artist,
album: song.album,
platform: provider.platform,
coverUrl: song.coverUrl,
});
this.emit('stateChange');
return `Now playing: ${song.name} - ${song.artist}`;
}
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return 'Usage: !add <song name>';
const provider = this.getProvider(cmd.flags.has('q'));
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
const song = result.songs[0];
const url = await provider.getSongUrl(song.id);
if (!url) return `Cannot get play URL for: ${song.name}`;
this.queue.add({ ...song, url, platform: provider.platform });
this.emit('stateChange');
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
}
private cmdPause(): string {
this.player.pause();
this.emit('stateChange');
return 'Paused';
}
private cmdResume(): string {
this.player.resume();
this.emit('stateChange');
return 'Resumed';
}
private cmdStop(): string {
this.player.stop();
this.queue.clear();
this.emit('stateChange');
return 'Stopped and queue cleared';
}
private cmdNext(): string {
this.playNext();
const current = this.queue.current();
if (current) return `Now playing: ${current.name} - ${current.artist}`;
return 'Queue is empty';
}
private cmdPrev(): string {
const prev = this.queue.prev();
if (prev) {
this.player.play(prev.url);
this.emit('stateChange');
return `Now playing: ${prev.name} - ${prev.artist}`;
}
return 'No previous song';
}
private cmdVol(cmd: ParsedCommand): string {
const vol = parseInt(cmd.args, 10);
if (isNaN(vol) || vol < 0 || vol > 100) return 'Usage: !vol <0-100>';
this.player.setVolume(vol);
this.emit('stateChange');
return `Volume set to ${vol}%`;
}
private cmdNow(): string {
const song = this.queue.current();
if (!song) return 'Nothing is playing';
return `Now playing: ${song.name} - ${song.artist} [${song.album}] (${song.platform})`;
}
private cmdQueue(): string {
const songs = this.queue.list();
if (songs.length === 0) return 'Queue is empty';
const currentIdx = this.queue.getCurrentIndex();
const lines = songs.map((s, i) => {
const marker = i === currentIdx ? '▶ ' : ' ';
return `${marker}${i + 1}. ${s.name} - ${s.artist}`;
});
return `Queue (${songs.length} songs, mode: ${this.queue.getMode()}):\n${lines.join('\n')}`;
}
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.emit('stateChange');
return 'Queue cleared';
}
private cmdRemove(cmd: ParsedCommand): string {
const index = parseInt(cmd.args, 10) - 1; // user sees 1-based
if (isNaN(index) || index < 0) return 'Usage: !remove <number>';
const removed = this.queue.remove(index);
if (!removed) return 'Invalid position';
this.emit('stateChange');
return `Removed: ${removed.name}`;
}
private cmdMode(cmd: ParsedCommand): string {
const modeMap: Record<string, PlayMode> = {
seq: PlayMode.Sequential,
loop: PlayMode.Loop,
random: PlayMode.Random,
rloop: PlayMode.RandomLoop,
};
const mode = modeMap[cmd.args];
if (!mode) return 'Usage: !mode <seq|loop|random|rloop>';
this.queue.setMode(mode);
this.emit('stateChange');
return `Play mode set to: ${cmd.args}`;
}
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return 'Usage: !playlist <playlist ID or URL>';
const provider = this.getProvider(cmd.flags.has('q'));
const id = this.extractId(cmd.args);
const songs = await provider.getPlaylistSongs(id);
if (songs.length === 0) return 'Playlist is empty or not found';
this.queue.clear();
for (const song of songs) {
const url = await provider.getSongUrl(song.id);
if (url) {
this.queue.add({ ...song, url, platform: provider.platform });
}
}
const first = this.queue.play();
if (first) this.player.play(first.url);
this.emit('stateChange');
return `Loaded playlist: ${songs.length} songs. Now playing: ${first?.name ?? 'unknown'}`;
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return 'Usage: !album <album ID>';
const provider = this.getProvider(cmd.flags.has('q'));
const songs = await provider.getAlbumSongs(cmd.args);
if (songs.length === 0) return 'Album is empty or not found';
this.queue.clear();
for (const song of songs) {
const url = await provider.getSongUrl(song.id);
if (url) {
this.queue.add({ ...song, url, platform: provider.platform });
}
}
const first = this.queue.play();
if (first) this.player.play(first.url);
this.emit('stateChange');
return `Loaded album: ${songs.length} songs. Now playing: ${first?.name ?? 'unknown'}`;
}
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return 'Personal FM is only available for NetEase Cloud Music';
}
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0) return 'No FM songs available (need to login first)';
this.queue.clear();
for (const song of songs) {
const url = await this.neteaseProvider.getSongUrl(song.id);
if (url) {
this.queue.add({ ...song, url, platform: 'netease' });
}
}
const first = this.queue.play();
if (first) this.player.play(first.url);
this.emit('stateChange');
return `Personal FM started: ${first?.name ?? 'unknown'} - ${first?.artist ?? ''}`;
}
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
if (!msg) return 'Vote can only be used in TeamSpeak';
this.voteSkipUsers.add(msg.invokerUid);
const clients = await this.tsClient.getClientsInChannel();
const totalUsers = clients.length - 1; // exclude bot
const needed = Math.ceil(totalUsers / 2);
const votes = this.voteSkipUsers.size;
if (votes >= needed) {
this.voteSkipUsers.clear();
this.playNext();
return `Vote passed (${votes}/${needed}). Skipping to next song.`;
}
return `Vote to skip: ${votes}/${needed} (need ${needed - votes} more)`;
}
private async cmdLyrics(): Promise<string> {
const song = this.queue.current();
if (!song) return 'Nothing is playing';
const provider = this.getProvider(song.platform === 'qq');
const lyrics = await provider.getLyrics(song.id);
if (lyrics.length === 0) return 'No lyrics available';
// Show first 10 lines
const lines = lyrics.slice(0, 10).map((l) => l.text);
return `Lyrics for ${song.name}:\n${lines.join('\n')}`;
}
private async cmdMove(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return 'Usage: !move <channel name or ID>';
await this.tsClient.moveToChannel(cmd.args);
return `Moved to channel: ${cmd.args}`;
}
private async cmdFollow(msg?: TS3TextMessage): Promise<string> {
if (!msg) return 'Follow can only be used in TeamSpeak';
// Would need to get the invoker's current channel
return 'Following you to your channel';
}
private cmdHelp(): string {
const p = this.config.commandPrefix;
return [
`TSMusicBot Commands:`,
`${p}play <song> — Search and play`,
`${p}play -q <song> — Search from QQ Music`,
`${p}add <song> — Add to queue`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <id> — Load playlist`,
`${p}album <id> — Load album`,
`${p}fm — Personal FM (NetEase)`,
`${p}vote — Vote to skip`,
`${p}lyrics — Show lyrics`,
`${p}now — Current song info`,
`${p}help — This help message`,
].join('\n');
}
private playNext(): void {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
this.player.play(next.url);
this.database.addPlayHistory({
botId: this.id,
songId: next.id,
songName: next.name,
artist: next.artist,
album: next.album,
platform: next.platform,
coverUrl: next.coverUrl,
});
} else {
this.player.stop();
}
this.emit('stateChange');
}
private extractId(input: string): string {
// Try to extract numeric ID from URL or return as-is
const match = input.match(/[?&]id=(\d+)/);
if (match) return match[1];
const pathMatch = input.match(/\/(\d+)/);
if (pathMatch) return pathMatch[1];
return input;
}
getStatus(): BotStatus {
return {
id: this.id,
name: this.name,
connected: this.connected,
playing: this.player.getState() === 'playing',
paused: this.player.getState() === 'paused',
currentSong: this.queue.current(),
queueSize: this.queue.size(),
volume: this.player.getVolume(),
playMode: this.queue.getMode(),
};
}
getQueue(): QueuedSong[] {
return this.queue.list();
}
getPlayer(): AudioPlayer {
return this.player;
}
getQueueManager(): PlayQueue {
return this.queue;
}
isConnected(): boolean {
return this.connected;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/bot/instance.ts
git commit -m "feat: add BotInstance with full command execution, player, queue, and TS integration"
```
---
### Task 3: BotManager (multi-instance)
**Files:**
- Create: `src/bot/manager.ts`
- [ ] **Step 1: Write implementation**
Create `src/bot/manager.ts`:
```typescript
import crypto from 'node:crypto';
import { BotInstance, type BotInstanceOptions } from './instance.js';
import type { MusicProvider } from '../music/provider.js';
import type { Database, BotInstanceRow } from '../data/database.js';
import type { BotConfig } from '../data/config.js';
import type { Logger } from '../logger.js';
export interface CreateBotParams {
name: string;
serverAddress: string;
serverPort: number;
queryPort?: number;
nickname: string;
defaultChannel?: string;
channelPassword?: string;
autoStart?: boolean;
}
export class BotManager {
private bots = new Map<string, BotInstance>();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private database: Database;
private config: BotConfig;
private logger: Logger;
constructor(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
database: Database,
config: BotConfig,
logger: Logger,
) {
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.database = database;
this.config = config;
this.logger = logger;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
const id = crypto.randomUUID();
const bot = new BotInstance({
id,
name: params.name,
tsOptions: {
host: params.serverAddress,
port: params.serverPort,
queryPort: params.queryPort ?? 10011,
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelPassword: params.channelPassword,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(id, bot);
// Save to database
this.database.saveBotInstance({
id,
name: params.name,
serverAddress: params.serverAddress,
serverPort: params.serverPort,
nickname: params.nickname,
defaultChannel: params.defaultChannel ?? '',
channelPassword: params.channelPassword ?? '',
autoStart: params.autoStart ?? false,
});
this.logger.info({ botId: id, name: params.name }, 'Bot instance created');
return bot;
}
async removeBot(id: string): Promise<void> {
const bot = this.bots.get(id);
if (bot) {
bot.disconnect();
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.logger.info({ botId: id }, 'Bot instance removed');
}
getBot(id: string): BotInstance | undefined {
return this.bots.get(id);
}
getAllBots(): BotInstance[] {
return Array.from(this.bots.values());
}
async startBot(id: string): Promise<void> {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect();
}
stopBot(id: string): void {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
bot.disconnect();
}
/**
* Load saved bot instances from database and create BotInstance objects.
* Optionally auto-start bots that have autoStart=true.
*/
async loadSavedBots(): Promise<void> {
const savedInstances = this.database.getBotInstances();
for (const saved of savedInstances) {
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
queryPort: 10011,
nickname: saved.nickname,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
database: this.database,
config: this.config,
logger: this.logger,
});
this.bots.set(saved.id, bot);
if (saved.autoStart) {
try {
await bot.connect();
this.logger.info({ botId: saved.id, name: saved.name }, 'Auto-started bot');
} catch (err) {
this.logger.error({ err, botId: saved.id }, 'Failed to auto-start bot');
}
}
}
this.logger.info({ count: savedInstances.length }, 'Loaded saved bot instances');
}
shutdown(): void {
for (const bot of this.bots.values()) {
bot.disconnect();
}
this.bots.clear();
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/bot/manager.ts
git commit -m "feat: add BotManager for multi-instance lifecycle, persistence, and auto-start"
```
---
### Task 4: Verify and commit Phase 5
- [ ] **Step 1: Run all tests**
Run: `npx vitest run`
Expected: All tests pass
- [ ] **Step 2: Verify TypeScript compilation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "chore: Phase 5 complete — bot core (commands, instance, manager)"
```
@@ -0,0 +1,856 @@
# TSMusicBot Phase 6: Web Backend (Express API + WebSocket)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Express REST API and WebSocket server that the Vue.js frontend will consume. Covers bot management, music search/playback control, auth, and real-time state updates.
**Architecture:** `src/web/server.ts` bootstraps Express + WebSocket. API routes are split by domain: `bot.ts`, `music.ts`, `player.ts`, `auth.ts`. WebSocket pushes state changes in real-time.
**Tech Stack:** Express 4, ws, vitest
---
### Task 1: Web server bootstrap
**Files:**
- Create: `src/web/server.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/server.ts`:
```typescript
import express from 'express';
import http from 'node:http';
import path from 'node:path';
import { WebSocketServer } from 'ws';
import type { BotManager } from '../bot/manager.js';
import type { MusicProvider } from '../music/provider.js';
import type { Database } from '../data/database.js';
import type { BotConfig } from '../data/config.js';
import type { Logger } from '../logger.js';
import { createBotRouter } from './api/bot.js';
import { createMusicRouter } from './api/music.js';
import { createPlayerRouter } from './api/player.js';
import { createAuthRouter } from './api/auth.js';
import { setupWebSocket } from './websocket.js';
export interface WebServerOptions {
port: number;
botManager: BotManager;
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
database: Database;
config: BotConfig;
logger: Logger;
staticDir?: string; // path to built Vue.js SPA
}
export interface WebServer {
start(): Promise<void>;
stop(): void;
}
export function createWebServer(options: WebServerOptions): WebServer {
const app = express();
const server = http.createServer(app);
const logger = options.logger.child({ component: 'web' });
// Middleware
app.use(express.json());
// API routes
app.use('/api/bot', createBotRouter(options.botManager, options.config, logger));
app.use('/api/music', createMusicRouter(options.neteaseProvider, options.qqProvider, logger));
app.use('/api/player', createPlayerRouter(options.botManager, logger));
app.use('/api/auth', createAuthRouter(options.neteaseProvider, options.qqProvider, logger));
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', version: '0.1.0' });
});
// Serve Vue.js SPA static files
if (options.staticDir) {
app.use(express.static(options.staticDir));
// SPA fallback: serve index.html for all non-API routes
app.get('*', (_req, res) => {
res.sendFile(path.join(options.staticDir!, 'index.html'));
});
}
// WebSocket
const wss = new WebSocketServer({ server, path: '/ws' });
setupWebSocket(wss, options.botManager, logger);
return {
async start(): Promise<void> {
return new Promise((resolve) => {
server.listen(options.port, () => {
logger.info({ port: options.port }, 'Web server started');
resolve();
});
});
},
stop(): void {
wss.close();
server.close();
},
};
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/server.ts
git commit -m "feat: add Express + WebSocket web server bootstrap"
```
---
### Task 2: Bot management API
**Files:**
- Create: `src/web/api/bot.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/api/bot.ts`:
```typescript
import { Router } from 'express';
import type { BotManager } from '../../bot/manager.js';
import type { BotConfig } from '../../data/config.js';
import type { Logger } from '../../logger.js';
export function createBotRouter(botManager: BotManager, config: BotConfig, logger: Logger): Router {
const router = Router();
// List all bot instances
router.get('/', (_req, res) => {
const bots = botManager.getAllBots().map((b) => b.getStatus());
res.json({ bots });
});
// Get single bot status
router.get('/:id', (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot) {
res.status(404).json({ error: 'Bot not found' });
return;
}
res.json(bot.getStatus());
});
// Create new bot instance
router.post('/', async (req, res) => {
try {
const { name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart } = req.body;
if (!name || !serverAddress || !nickname) {
res.status(400).json({ error: 'name, serverAddress, and nickname are required' });
return;
}
const bot = await botManager.createBot({
name,
serverAddress,
serverPort: serverPort ?? 9987,
nickname,
defaultChannel,
channelPassword,
autoStart: autoStart ?? false,
});
res.status(201).json(bot.getStatus());
} catch (err) {
logger.error({ err }, 'Failed to create bot');
res.status(500).json({ error: (err as Error).message });
}
});
// Delete bot instance
router.delete('/:id', async (req, res) => {
try {
await botManager.removeBot(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Start bot (connect to TS server)
router.post('/:id/start', async (req, res) => {
try {
await botManager.startBot(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Stop bot (disconnect from TS server)
router.post('/:id/stop', (req, res) => {
try {
botManager.stopBot(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
return router;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/api/bot.ts
git commit -m "feat: add bot management REST API (CRUD + start/stop)"
```
---
### Task 3: Music search API
**Files:**
- Create: `src/web/api/music.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/api/music.ts`:
```typescript
import { Router } from 'express';
import type { MusicProvider } from '../../music/provider.js';
import type { Logger } from '../../logger.js';
export function createMusicRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
logger: Logger,
): Router {
const router = Router();
function getProvider(platform?: string): MusicProvider {
return platform === 'qq' ? qqProvider : neteaseProvider;
}
// Search
router.get('/search', async (req, res) => {
try {
const { q, platform, limit } = req.query;
if (!q) {
res.status(400).json({ error: 'q (query) is required' });
return;
}
const provider = getProvider(platform as string);
const result = await provider.search(q as string, parseInt(limit as string) || 20);
res.json(result);
} catch (err) {
logger.error({ err }, 'Search failed');
res.status(500).json({ error: (err as Error).message });
}
});
// Song detail
router.get('/song/:id', async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const song = await provider.getSongDetail(req.params.id);
if (!song) {
res.status(404).json({ error: 'Song not found' });
return;
}
res.json(song);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Playlist songs
router.get('/playlist/:id', async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const songs = await provider.getPlaylistSongs(req.params.id);
res.json({ songs });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Recommended playlists
router.get('/recommend/playlists', async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const playlists = await provider.getRecommendPlaylists();
res.json({ playlists });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Album songs
router.get('/album/:id', async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const songs = await provider.getAlbumSongs(req.params.id);
res.json({ songs });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Lyrics
router.get('/lyrics/:id', async (req, res) => {
try {
const provider = getProvider(req.query.platform as string);
const lyrics = await provider.getLyrics(req.params.id);
res.json({ lyrics });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
return router;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/api/music.ts
git commit -m "feat: add music search/detail/playlist/lyrics REST API"
```
---
### Task 4: Player control API
**Files:**
- Create: `src/web/api/player.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/api/player.ts`:
```typescript
import { Router } from 'express';
import type { BotManager } from '../../bot/manager.js';
import type { Logger } from '../../logger.js';
import { parseCommand } from '../../bot/commands.js';
export function createPlayerRouter(botManager: BotManager, logger: Logger): Router {
const router = Router();
// All player routes require a botId
router.use('/:botId', (req, res, next) => {
const bot = botManager.getBot(req.params.botId);
if (!bot) {
res.status(404).json({ error: 'Bot not found' });
return;
}
(req as any).bot = bot;
next();
});
// Play a song
router.post('/:botId/play', async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
if (!query) {
res.status(400).json({ error: 'query is required' });
return;
}
const flags = platform === 'qq' ? '-q' : '';
const cmd = parseCommand(`!play ${flags} ${query}`.trim(), '!');
if (!cmd) {
res.status(400).json({ error: 'Invalid command' });
return;
}
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Add to queue
router.post('/:botId/add', async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
const flags = platform === 'qq' ? '-q' : '';
const cmd = parseCommand(`!add ${flags} ${query}`.trim(), '!');
if (!cmd) {
res.status(400).json({ error: 'Invalid command' });
return;
}
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Pause
router.post('/:botId/pause', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!pause', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Resume
router.post('/:botId/resume', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!resume', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Next
router.post('/:botId/next', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!next', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Previous
router.post('/:botId/prev', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!prev', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Stop
router.post('/:botId/stop', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!stop', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Set volume
router.post('/:botId/volume', async (req, res) => {
const bot = (req as any).bot;
const { volume } = req.body;
const cmd = parseCommand(`!vol ${volume}`, '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Set play mode
router.post('/:botId/mode', async (req, res) => {
const bot = (req as any).bot;
const { mode } = req.body;
const cmd = parseCommand(`!mode ${mode}`, '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Get queue
router.get('/:botId/queue', (req, res) => {
const bot = (req as any).bot;
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
});
// Clear queue
router.post('/:botId/clear', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand('!clear', '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Remove from queue
router.delete('/:botId/queue/:index', async (req, res) => {
const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
});
// Load playlist
router.post('/:botId/playlist', async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
const flags = platform === 'qq' ? '-q' : '';
const cmd = parseCommand(`!playlist ${flags} ${playlistId}`.trim(), '!')!;
const response = await bot.executeCommand(cmd);
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Play history
router.get('/:botId/history', (req, res) => {
const bot = (req as any).bot;
const limit = parseInt(req.query.limit as string) || 50;
// Access database directly through the bot's status
// The database is accessed through the botManager
res.json({ history: [] }); // Will be wired properly when database is accessible
});
return router;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/api/player.ts
git commit -m "feat: add player control REST API (play, pause, queue, volume, mode)"
```
---
### Task 5: Auth API
**Files:**
- Create: `src/web/api/auth.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/api/auth.ts`:
```typescript
import { Router } from 'express';
import type { MusicProvider } from '../../music/provider.js';
import type { Logger } from '../../logger.js';
export function createAuthRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
logger: Logger,
): Router {
const router = Router();
function getProvider(platform?: string): MusicProvider {
return platform === 'qq' ? qqProvider : neteaseProvider;
}
// Get auth status
router.get('/status', async (req, res) => {
try {
const platform = req.query.platform as string;
const provider = getProvider(platform);
const status = await provider.getAuthStatus();
res.json({ platform: provider.platform, ...status });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Get QR code for login
router.post('/qrcode', async (req, res) => {
try {
const { platform } = req.body;
const provider = getProvider(platform);
const qr = await provider.getQrCode();
res.json(qr);
} catch (err) {
logger.error({ err }, 'QR code generation failed');
res.status(500).json({ error: (err as Error).message });
}
});
// Check QR code status
router.get('/qrcode/status', async (req, res) => {
try {
const { key, platform } = req.query;
if (!key) {
res.status(400).json({ error: 'key is required' });
return;
}
const provider = getProvider(platform as string);
const status = await provider.checkQrCodeStatus(key as string);
res.json({ status });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Send SMS code (NetEase only)
router.post('/sms/send', async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
res.status(400).json({ error: 'phone is required' });
return;
}
if (!neteaseProvider.sendSmsCode) {
res.status(400).json({ error: 'SMS login not supported for this platform' });
return;
}
const success = await neteaseProvider.sendSmsCode(phone);
res.json({ success });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Verify SMS code (NetEase only)
router.post('/sms/verify', async (req, res) => {
try {
const { phone, code } = req.body;
if (!phone || !code) {
res.status(400).json({ error: 'phone and code are required' });
return;
}
if (!neteaseProvider.loginWithSms) {
res.status(400).json({ error: 'SMS login not supported' });
return;
}
const success = await neteaseProvider.loginWithSms(phone, code);
res.json({ success });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// Set cookie manually
router.post('/cookie', (req, res) => {
const { platform, cookie } = req.body;
if (!cookie) {
res.status(400).json({ error: 'cookie is required' });
return;
}
const provider = getProvider(platform);
provider.setCookie(cookie);
res.json({ success: true });
});
return router;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/api/auth.ts
git commit -m "feat: add auth REST API (QR code, SMS, cookie login)"
```
---
### Task 6: WebSocket real-time updates
**Files:**
- Create: `src/web/websocket.ts`
- [ ] **Step 1: Write implementation**
Create `src/web/websocket.ts`:
```typescript
import { WebSocketServer, WebSocket } from 'ws';
import type { BotManager } from '../bot/manager.js';
import type { Logger } from '../logger.js';
export function setupWebSocket(wss: WebSocketServer, botManager: BotManager, logger: Logger): void {
const clients = new Set<WebSocket>();
wss.on('connection', (ws) => {
clients.add(ws);
logger.debug('WebSocket client connected');
// Send initial state
const bots = botManager.getAllBots().map((b) => b.getStatus());
ws.send(JSON.stringify({ type: 'init', bots }));
ws.on('close', () => {
clients.delete(ws);
logger.debug('WebSocket client disconnected');
});
ws.on('error', (err) => {
logger.error({ err }, 'WebSocket error');
clients.delete(ws);
});
});
// Broadcast state changes from all bots
const broadcast = (data: object) => {
const message = JSON.stringify(data);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
};
// Listen for state changes on all bots
// Re-attach listeners when bots are added/removed
const attachBotListeners = () => {
for (const bot of botManager.getAllBots()) {
// Remove existing listeners to avoid duplicates
bot.removeAllListeners('stateChange');
bot.removeAllListeners('connected');
bot.removeAllListeners('disconnected');
bot.on('stateChange', () => {
broadcast({
type: 'stateChange',
botId: bot.id,
status: bot.getStatus(),
queue: bot.getQueue(),
});
});
bot.on('connected', () => {
broadcast({ type: 'botConnected', botId: bot.id, status: bot.getStatus() });
});
bot.on('disconnected', () => {
broadcast({ type: 'botDisconnected', botId: bot.id });
});
}
};
// Refresh listeners periodically (simple approach)
setInterval(attachBotListeners, 5000);
attachBotListeners();
}
```
- [ ] **Step 2: Commit**
```bash
git add src/web/websocket.ts
git commit -m "feat: add WebSocket real-time state broadcasting"
```
---
### Task 7: Wire everything in index.ts
**Files:**
- Modify: `src/index.ts`
- [ ] **Step 1: Update entry point**
Replace the contents of `src/index.ts`:
```typescript
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig, saveConfig } from './data/config.js';
import { createDatabase } from './data/database.js';
import { createLogger } from './logger.js';
import { createApiServerManager } from './music/api-server.js';
import { NeteaseProvider } from './music/netease.js';
import { QQMusicProvider } from './music/qq.js';
import { createCookieStore } from './music/auth.js';
import { BotManager } from './bot/manager.js';
import { createWebServer } from './web/server.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '..');
const DATA_DIR = path.join(ROOT_DIR, 'data');
const CONFIG_PATH = path.join(ROOT_DIR, 'config.json');
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 STATIC_DIR = path.join(ROOT_DIR, 'web', 'dist');
async function main() {
// Load config
const config = loadConfig(CONFIG_PATH);
saveConfig(CONFIG_PATH, config);
// Create logger
const logger = createLogger(LOG_DIR);
// Create database
const db = createDatabase(DB_PATH);
// Start embedded music API servers
const apiServer = createApiServerManager(
{ neteasePort: config.neteaseApiPort, qqMusicApiPort: config.qqMusicApiPort },
logger,
);
await apiServer.start();
// Create music providers
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
// Load saved cookies
const cookieStore = createCookieStore(COOKIE_DIR);
const neteaseCookie = cookieStore.load('netease');
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
const qqCookie = cookieStore.load('qq');
if (qqCookie) qqProvider.setCookie(qqCookie);
// Create bot manager
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
await botManager.loadSavedBots();
// Create and start web server
const webServer = createWebServer({
port: config.webPort,
botManager,
neteaseProvider,
qqProvider,
database: db,
config,
logger,
staticDir: STATIC_DIR,
});
await webServer.start();
logger.info({ webPort: config.webPort }, 'TSMusicBot started');
logger.info(`WebUI: http://localhost:${config.webPort}`);
// Graceful shutdown
const shutdown = () => {
logger.info('Shutting down...');
botManager.shutdown();
webServer.stop();
apiServer.stop();
db.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
```
- [ ] **Step 2: Verify TypeScript compilation**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 3: Commit**
```bash
git add src/index.ts
git commit -m "feat: wire all components in entry point — full backend stack"
```
---
### Task 8: Verify Phase 6
- [ ] **Step 1: Run all tests**
Run: `npx vitest run`
Expected: All tests pass
- [ ] **Step 2: Commit**
```bash
git add -A
git commit -m "chore: Phase 6 complete — web backend (REST API, WebSocket, full wiring)"
```
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,552 @@
# TSMusicBot Phase 8: Deployment & Packaging
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create one-click install experiences for Windows (.exe + portable .zip) and Linux (install script + Docker), plus the first-run Setup Wizard in the WebUI.
**Architecture:** Windows uses `pkg` to bundle Node.js into a single .exe with FFmpeg as a sidecar binary. Linux uses a shell script that installs dependencies and configures systemd, plus a Docker image. The Setup Wizard is a Vue.js view that guides users through initial configuration.
**Tech Stack:** pkg, Inno Setup/NSIS, Docker, bash
---
### Task 1: Windows start script
**Files:**
- Create: `scripts/start.bat`
- [ ] **Step 1: Create start.bat**
```bat
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed. Please install Node.js 20 LTS from https://nodejs.org
pause
exit /b 1
)
:: Check if ffmpeg is available
where ffmpeg >nul 2>&1
if %errorlevel% neq 0 (
echo FFmpeg not found in PATH. Checking local directory...
if exist "%~dp0ffmpeg\ffmpeg.exe" (
set PATH=%~dp0ffmpeg;%PATH%
echo Using bundled FFmpeg.
) else (
echo FFmpeg is required. Please install FFmpeg or place it in the ffmpeg\ directory.
pause
exit /b 1
)
)
:: Install dependencies if needed
if not exist "%~dp0node_modules" (
echo Installing dependencies...
cd /d "%~dp0"
call npm install --production
)
:: Start the application
cd /d "%~dp0"
node dist/index.js
pause
```
- [ ] **Step 2: Commit**
```bash
git add scripts/start.bat
git commit -m "feat: add Windows start script with dependency checks"
```
---
### Task 2: Linux install script
**Files:**
- Create: `scripts/install.sh`
- [ ] **Step 1: Create install.sh**
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
echo ""
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
else
echo "Unsupported OS"
exit 1
fi
echo "[1/5] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl ffmpeg
;;
centos|rhel|fedora)
sudo yum install -y curl ffmpeg
;;
arch|manjaro)
sudo pacman -S --noconfirm curl ffmpeg
;;
*)
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
;;
esac
echo "[2/5] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/5] Downloading TSMusicBot..."
sudo mkdir -p "$INSTALL_DIR"
# In production, this would download from GitHub releases
# For now, copy from current directory
if [ -d "$(pwd)/dist" ]; then
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
else
echo "Please run this script from the TSMusicBot source directory after building."
exit 1
fi
echo "[4/5] Installing npm dependencies..."
cd "$INSTALL_DIR"
sudo npm install --production
echo "[5/5] Creating systemd service..."
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOL
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot installed and running! ║"
echo "║ ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ ║"
echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
echo "╚══════════════════════════════════════╝"
```
- [ ] **Step 2: Make executable**
```bash
chmod +x scripts/install.sh
```
- [ ] **Step 3: Commit**
```bash
git add scripts/install.sh
git commit -m "feat: add Linux one-click install script with systemd service"
```
---
### Task 3: Docker setup
**Files:**
- Create: `scripts/docker/Dockerfile`
- Create: `scripts/docker/docker-compose.yml`
- [ ] **Step 1: Create Dockerfile**
```dockerfile
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
RUN npm ci --production
EXPOSE 3000
VOLUME ["/app/data"]
CMD ["node", "dist/index.js"]
```
- [ ] **Step 2: Create docker-compose.yml**
```yaml
version: '3.8'
services:
tsmusicbot:
build:
context: ../..
dockerfile: scripts/docker/Dockerfile
container_name: tsmusicbot
ports:
- "3000:3000"
volumes:
- ./data:/app/data
restart: unless-stopped
environment:
- NODE_ENV=production
```
- [ ] **Step 3: Commit**
```bash
git add scripts/docker/
git commit -m "feat: add Dockerfile and docker-compose.yml"
```
---
### Task 4: Setup Wizard (first-run flow)
**Files:**
- Create: `web/src/views/Setup.vue`
- Modify: `web/src/router/index.ts` (add setup route)
- [ ] **Step 1: Create Setup wizard view**
Create `web/src/views/Setup.vue` — A 4-step wizard:
1. Welcome: set admin password, language, theme
2. TS Server: address, port, nickname, default channel, test connection button
3. Music Account (optional): QR code login for NetEase/QQ, skip button
4. Done: success message, redirect to home
```vue
<template>
<div class="setup-wizard">
<!-- Step Indicator -->
<div class="steps">
<div v-for="(label, i) in stepLabels" :key="i" class="step" :class="{ active: currentStep === i, done: currentStep > i }">
<div class="step-dot">{{ currentStep > i ? '✓' : i + 1 }}</div>
<div class="step-label">{{ label }}</div>
</div>
</div>
<!-- Step 1: Welcome -->
<div v-if="currentStep === 0" class="step-content">
<h2>欢迎使用 TSMusicBot</h2>
<p class="subtitle">请设置管理员密码以保护你的 WebUI</p>
<div class="form-group">
<label>管理员密码</label>
<input type="password" v-model="adminPassword" placeholder="设置密码" class="input" />
</div>
<div class="form-group">
<label>语言</label>
<select v-model="locale" class="input">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</div>
<div class="form-group">
<label>主题</label>
<select v-model="theme" class="input">
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</div>
<button class="btn-primary" @click="currentStep = 1">下一步</button>
</div>
<!-- Step 2: TS Server -->
<div v-if="currentStep === 1" class="step-content">
<h2>连接 TeamSpeak 服务器</h2>
<div class="form-group">
<label>服务器地址</label>
<input v-model="serverAddress" placeholder="ts.example.com" class="input" />
</div>
<div class="form-group">
<label>端口</label>
<input v-model.number="serverPort" type="number" placeholder="9987" class="input" />
</div>
<div class="form-group">
<label>机器人昵称</label>
<input v-model="nickname" placeholder="MusicBot" class="input" />
</div>
<div class="form-group">
<label>默认频道 (可选)</label>
<input v-model="defaultChannel" placeholder="音乐频道" class="input" />
</div>
<div class="btn-row">
<button class="btn-secondary" @click="currentStep = 0">上一步</button>
<button class="btn-primary" @click="createBotAndNext">下一步</button>
</div>
</div>
<!-- Step 3: Music Account -->
<div v-if="currentStep === 2" class="step-content">
<h2>登录音乐账号 (可选)</h2>
<p class="subtitle">登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲</p>
<div class="btn-row">
<button class="btn-secondary" @click="currentStep = 1">上一步</button>
<button class="btn-secondary" @click="currentStep = 3">跳过</button>
<button class="btn-primary" @click="currentStep = 3">完成登录</button>
</div>
</div>
<!-- Step 4: Done -->
<div v-if="currentStep === 3" class="step-content done-step">
<h2>设置完成!</h2>
<p class="subtitle">TSMusicBot 已准备就绪</p>
<button class="btn-primary" @click="$router.push('/')">开始使用</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
const router = useRouter();
const currentStep = ref(0);
const stepLabels = ['欢迎', 'TS 服务器', '音乐账号', '完成'];
const adminPassword = ref('');
const locale = ref('zh');
const theme = ref('dark');
const serverAddress = ref('');
const serverPort = ref(9987);
const nickname = ref('MusicBot');
const defaultChannel = ref('');
async function createBotAndNext() {
try {
await axios.post('/api/bot', {
name: `Bot - ${serverAddress.value}`,
serverAddress: serverAddress.value,
serverPort: serverPort.value,
nickname: nickname.value,
defaultChannel: defaultChannel.value,
autoStart: true,
});
currentStep.value = 2;
} catch (err) {
alert('Failed to create bot: ' + (err as Error).message);
}
}
</script>
<style lang="scss" scoped>
.setup-wizard {
max-width: 560px;
margin: 0 auto;
padding-top: 40px;
}
.steps {
display: flex;
justify-content: space-between;
margin-bottom: 48px;
}
.step {
text-align: center;
flex: 1;
}
.step-dot {
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
background: var(--hover-bg);
margin-bottom: 8px;
opacity: 0.5;
}
.step.active .step-dot {
background: var(--color-primary);
color: white;
opacity: 1;
}
.step.done .step-dot {
background: var(--color-primary);
color: white;
opacity: 0.7;
}
.step-label {
font-size: 12px;
opacity: 0.5;
}
.step.active .step-label { opacity: 1; color: var(--color-primary); }
.step-content h2 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
margin-bottom: 32px;
}
.form-group {
margin-bottom: 20px;
label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
opacity: 0.8;
}
}
.input {
width: 100%;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-primary);
outline: none;
font-family: inherit;
&:focus {
border-color: var(--color-primary);
}
}
.btn-primary {
padding: 10px 32px;
background: var(--color-primary);
color: white;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
transition: transform var(--transition-fast);
&:hover { transform: scale(1.04); }
&:active { transform: scale(0.96); }
}
.btn-secondary {
padding: 10px 32px;
background: var(--hover-bg);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
}
.btn-row {
display: flex;
gap: 12px;
margin-top: 32px;
}
.done-step {
text-align: center;
padding-top: 60px;
}
</style>
```
- [ ] **Step 2: Add setup route to router**
Add to `web/src/router/index.ts` routes array:
```typescript
{
path: '/setup',
name: 'setup',
component: () => import('../views/Setup.vue'),
},
```
- [ ] **Step 3: Commit**
```bash
git add web/src/views/Setup.vue web/src/router/index.ts
git commit -m "feat: add first-run Setup Wizard (4-step guided configuration)"
```
---
### Task 5: Final build and verify
- [ ] **Step 1: Build full project**
```bash
cd "C:/Users/saopig1/Music/teamspeak music bot"
npm run build
```
Expected: `dist/` (backend) and `web/dist/` (frontend) both exist.
- [ ] **Step 2: Run application**
```bash
node dist/index.js
```
Expected: Console shows "TSMusicBot started" and "WebUI: http://localhost:3000"
- [ ] **Step 3: Final commit**
```bash
git add -A
git commit -m "chore: Phase 8 complete — deployment scripts, Docker, Setup Wizard. TSMusicBot v0.1.0"
```
@@ -0,0 +1,372 @@
# TSMusicBot 设计文档
**日期**: 2026-03-29
**状态**: 已批准
## 概述
TSMusicBot 是一个从零构建的 TeamSpeak 音乐机器人,支持播放网易云音乐和 QQ 音乐。机器人作为 TeamSpeak 客户端连接服务器,用户可通过精美的 WebUI 或 TeamSpeak 内文字命令进行操控。安装体验为一键式,面向不懂代码的用户。
## 1. 架构设计
### 整体架构
单体架构(Monolith),所有组件运行在一个 Node.js 进程中。
```
┌─────────────────────────────────────────────────────────────────┐
│ TSMusicBot (单体进程) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ WebUI 层 │ │ TS 命令层 │ │ Bot 管理器 │ │
│ │ │ │ │ │ │ │
│ │ Vue.js SPA │ │ !play !next │ │ BotInstance #1 │ │
│ │ Express API │ │ !pause !vol │ │ BotInstance #2 │ │
│ │ WebSocket │ │ !queue !skip │ │ BotInstance #N │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └─────────────────┴──────────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ 核心服务层 │ │
│ │ │ │
│ │ ┌───────────────────┐ │ │
│ │ │ 播放引擎 │ │ │
│ │ │ FFmpeg 解码 │ │ │
│ │ │ Opus 编码 │ │ │
│ │ │ 播放队列管理 │ │ │
│ │ └───────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────┐ │ │
│ │ │ 音乐源服务 │ │ │
│ │ │ 网易云 API │ │ │
│ │ │ QQ音乐 API │ │ │
│ │ │ 搜索/歌单/专辑 │ │ │
│ │ └───────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────┐ │ │
│ │ │ TS 协议层 │ │ │
│ │ │ TCP 命令通道 │ │ │
│ │ │ UDP 语音通道 │ │ │
│ │ │ (无加密) │ │ │
│ │ └───────────────────┘ │ │
│ └─────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 数据层 │ │
│ │ SQLite (用户数据/播放记录) + JSON (配置/Cookie) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 六大核心模块
1. **TS 协议层** — 实现 TeamSpeak 3 客户端协议(参考 TSLib),包括 TCP 命令通道(登录、频道操作、文字消息)和 UDP 语音通道(Opus 音频包发送)。不实现加密。
2. **播放引擎** — 通过 FFmpeg 子进程解码音频源(MP3/FLAC/AAC),输出 PCM 数据,再用 node-opus 编码为 Opus 格式,按 20ms 帧发送到 TS 语音通道。管理播放队列、循环模式、音量控制。
3. **音乐源服务** — 内嵌启动 NeteaseCloudMusicApi 和 QQMusicApi 服务,提供统一的搜索、获取歌曲 URL、歌单、专辑、歌词等接口。处理账号登录(扫码/短信/Cookie)和 Cookie 自动刷新。
4. **WebUI + API** — Express 提供 REST API 和静态文件服务(Vue.js SPA)。WebSocket 实现实时状态推送(当前播放、队列变化、在线用户)。
5. **Bot 管理器** — 管理多个 BotInstance 的生命周期。每个实例拥有独立的 TS 连接、播放引擎和队列。支持通过 WebUI 创建/删除/配置实例。
6. **TS 命令层** — 监听 TS 频道文字消息,解析命令(如 !play、!next、!vol),权限检查后转发给对应的 BotInstance 执行。支持自定义命令前缀和别名。
### 数据流
```
用户点歌 (!play 晴天 或 WebUI 搜索)
→ 命令层/API 层调用音乐源服务搜索歌曲
→ 音乐源服务从网易云/QQ音乐 API 获取歌曲 URL
→ 播放引擎: FFmpeg 下载并解码为 PCM → Opus 编码
→ TS 协议层: 通过 UDP 将 Opus 音频包发送到 TS 服务器
→ WebSocket: 推送播放状态更新到 WebUI
```
## 2. WebUI 设计
### 设计风格
参考 YesPlayMusic(Apple Music 风格),核心设计要素:
- 深色主题为默认,支持浅色主题切换
- 毛玻璃导航栏和播放栏 (`backdrop-filter: blur(20px)`)
- 专辑封面彩色阴影效果(`node-vibrant` 动态提取主色)
- 大字号标题(22-42px),Barlow + PingFang SC / Microsoft YaHei 字体
- 圆角卡片(10-14px radius),充足留白
- 流畅微交互(hover scale 1.04, active scale 0.96, 0.3s transitions)
- 沉浸式歌词页 — 全屏、模糊背景、动态色彩
### 整体布局
- 固定毛玻璃导航栏(56px,顶部):Logo、导航链接(发现/搜索/歌单/播放历史)、Bot 状态指示、管理入口
- 主内容区:10vw 水平内边距,Bot 实例切换器 + 主要内容 + 右侧边栏(队列/频道信息)
- 固定毛玻璃播放栏(56px,底部):当前歌曲信息、播放控制、音量/队列/歌词入口
### 页面清单
1. **发现(首页)** — 推荐歌单、热门排行、正在播放状态、快速搜索入口
2. **搜索** — 跨平台搜索(网易云/QQ音乐切换),结果展示歌曲/歌单/专辑/歌手
3. **歌单详情** — 歌单封面、歌曲列表、一键播放全部、逐首添加到队列
4. **歌词页** — 沉浸式全屏歌词,动态背景(专辑色彩提取),逐行高亮
5. **播放历史** — 播放记录,按时间/平台筛选,快速重播
6. **设置/管理** — Bot 实例管理、TS 服务器连接配置、音乐账号登录(扫码/短信/Cookie)、命令前缀设置、频道行为配置
## 3. TeamSpeak 命令系统
用户在 TeamSpeak 频道内发送文字消息控制机器人。默认命令前缀 `!`,可在 WebUI 中自定义。支持私聊和频道消息两种触发方式。
### 播放控制
| 命令 | 说明 |
|------|------|
| `!play <歌名/URL/ID>` | 搜索并播放歌曲(默认网易云,加 -q 切 QQ 音乐) |
| `!play -q <歌名>` | 从 QQ 音乐搜索并播放 |
| `!pause` | 暂停播放 |
| `!resume` | 恢复播放 |
| `!stop` | 停止播放并清空队列 |
| `!next` / `!skip` | 下一首 |
| `!prev` | 上一首 |
| `!vol <0-100>` | 设置音量 |
| `!now` | 显示当前播放歌曲信息 |
### 队列管理
| 命令 | 说明 |
|------|------|
| `!add <歌名/URL/ID>` | 添加歌曲到队列尾部(不立即播放) |
| `!queue` / `!list` | 显示当前播放队列 |
| `!clear` | 清空播放队列 |
| `!remove <序号>` | 移除队列中指定位置的歌曲 |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式:顺序/单曲循环/随机/随机循环 |
### 歌单 & 专辑
| 命令 | 说明 |
|------|------|
| `!playlist <歌单ID/URL>` | 加载歌单并开始播放 |
| `!album <专辑ID/URL>` | 加载专辑并开始播放 |
| `!fm` | 开启私人 FM 模式(网易云) |
### 社交互动
| 命令 | 说明 |
|------|------|
| `!vote` | 发起投票切歌(频道内过半数同意即跳过) |
| `!lyrics` | 在频道消息中显示当前歌词片段 |
### 管理命令
| 命令 | 说明 |
|------|------|
| `!move <频道名/ID>` | 移动机器人到指定频道 |
| `!follow` | 机器人跟随你到你所在的频道 |
| `!help` | 显示命令帮助列表 |
### 命令系统特性
- 命令前缀可自定义(默认 `!`),在 WebUI 设置页修改
- 命令别名可配置(如 `!p` → `!play`)
- 搜索结果多首时,回复序号选择
- 支持私聊和频道消息两种触发方式
### 权限系统
- **所有人**: play, add, queue, now, lyrics, vote, help
- **管理员**: stop, clear, move, vol, mode, follow
- 管理员通过 TS Server Group 或 WebUI 配置指定
- WebUI 管理页面需要密码登录
## 4. 技术栈
### 后端
| 依赖 | 用途 |
|------|------|
| Node.js 20 LTS | 运行时 |
| TypeScript 5.x | 开发语言 |
| Express 4.x | Web 框架 |
| ws | WebSocket |
| better-sqlite3 | SQLite 数据库 |
| FFmpeg (子进程) | 音频解码 |
| @discordjs/opus 或 opusscript | Opus 编码 |
| 自研 TS3 协议 (参考 TSLib) | TeamSpeak 连接 |
| Node.js crypto + tweetnacl | Ed25519 身份 |
| NeteaseCloudMusicApi (内嵌) | 网易云音乐 API |
| QQMusicApi (内嵌) | QQ 音乐 API |
| pino | 日志 |
### 前端
| 依赖 | 用途 |
|------|------|
| Vue 3 + Composition API | UI 框架 |
| Vite 5.x | 构建工具 |
| Vue Router 4 | 路由 |
| Pinia | 状态管理 |
| SCSS + CSS Variables | 样式 / 主题 |
| node-vibrant | 专辑封面主色提取 |
| Iconify | 图标 (按需加载) |
| axios | HTTP 客户端 |
| Barlow + 系统中文字体 | 排版 |
### 数据存储
- **SQLite** (better-sqlite3): 用户数据、播放记录、Bot 实例信息
- **JSON 文件**: 应用配置(方便手动编辑)、音乐平台 Cookie
## 5. 项目结构
```
TSMusicBot/
├── package.json
├── tsconfig.json
├── config.json # 用户配置(JSON,可手动编辑)
│
├── src/ # 后端源码
│ ├── index.ts # 入口:启动所有服务
│ │
│ ├── ts-protocol/ # TeamSpeak 3 客户端协议实现(无加密)
│ │ ├── connection.ts # TCP/UDP 连接管理
│ │ ├── identity.ts # TS3 身份生成与管理
│ │ ├── commands.ts # TS3 命令编解码
│ │ ├── voice.ts # 语音数据包发送/接收
│ │ └── client.ts # 高层 TS3 Client 封装
│ │
│ ├── audio/ # 音频处理
│ │ ├── player.ts # 播放引擎(FFmpeg → Opus → TS)
│ │ ├── queue.ts # 播放队列管理
│ │ └── encoder.ts # Opus 编码封装
│ │
│ ├── music/ # 音乐源服务
│ │ ├── provider.ts # 统一音乐源接口
│ │ ├── netease.ts # 网易云适配器
│ │ ├── qq.ts # QQ音乐适配器
│ │ ├── auth.ts # 账号认证(扫码/短信/Cookie)
│ │ └── api-server.ts # 内嵌 API 服务启动器
│ │
│ ├── bot/ # Bot 核心
│ │ ├── instance.ts # BotInstance(一个TS连接+播放引擎)
│ │ ├── manager.ts # 多实例管理器
│ │ └── commands.ts # TS 文字命令解析与执行
│ │
│ ├── web/ # Web 服务
│ │ ├── server.ts # Express + 静态文件 + WebSocket
│ │ ├── api/ # REST API 路由
│ │ │ ├── bot.ts # Bot 管理接口
│ │ │ ├── music.ts # 搜索/歌单/歌曲接口
│ │ │ ├── player.ts # 播放控制接口
│ │ │ └── auth.ts # 登录认证接口
│ │ └── websocket.ts # WebSocket 事件推送
│ │
│ └── data/ # 数据层
│ ├── database.ts # SQLite 封装
│ ├── config.ts # JSON 配置读写
│ └── migrations/ # 数据库迁移脚本
│
├── web/ # 前端源码 (Vue.js SPA)
│ ├── index.html
│ ├── vite.config.ts
│ ├── src/
│ │ ├── App.vue
│ │ ├── main.ts
│ │ ├── router/ # 路由定义
│ │ ├── stores/ # Pinia 状态管理
│ │ ├── views/ # 页面组件
│ │ │ ├── Home.vue # 发现/首页
│ │ │ ├── Search.vue # 搜索
│ │ │ ├── Playlist.vue # 歌单详情
│ │ │ ├── Lyrics.vue # 沉浸式歌词
│ │ │ ├── History.vue # 播放历史
│ │ │ └── Settings.vue # 设置/管理
│ │ ├── components/ # 可复用组件
│ │ │ ├── Player.vue # 底部播放栏
│ │ │ ├── Navbar.vue # 顶部导航栏
│ │ │ ├── Queue.vue # 播放队列面板
│ │ │ ├── SongCard.vue # 歌曲卡片
│ │ │ └── CoverArt.vue # 带彩色阴影的封面组件
│ │ ├── composables/ # 组合式函数
│ │ │ ├── useWebSocket.ts # WebSocket 连接
│ │ │ └── usePlayer.ts # 播放器状态
│ │ └── styles/ # 全局样式
│ │ ├── variables.scss # CSS 变量 / 主题
│ │ └── global.scss # 全局样式
│ └── public/
│
├── scripts/ # 部署脚本
│ ├── install.sh # Linux 一键安装
│ ├── install.bat # Windows 一键安装
│ └── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
│
└── data/ # 运行时数据(gitignore)
├── tsmusicbot.db # SQLite 数据库
├── cookies/ # 音乐平台 Cookie
└── logs/ # 日志文件
```
## 6. 部署与安装
### Windows
- **安装包 (.exe)**: 使用 pkg 打包 Node.js + 应用为单个 .exe,FFmpeg 作为附带二进制文件,Inno Setup 生成安装程序。双击安装 → 桌面快捷方式 → 双击启动 → 自动打开浏览器。
- **便携版 (.zip)**: 解压即用,包含所有依赖,双击 `start.bat` 启动。
### Linux
- **一键脚本**: `curl -fsSL https://get.tsmusicbot.com | bash` — 自动检测系统、安装 Node.js/FFmpeg、下载 TSMusicBot、配置 systemd 服务、启动。
- **Docker**: `docker run -d -p 3000:3000 tsmusicbot/tsmusicbot` — 镜像基于 node:20-slim,内含 FFmpeg,映射 `./data` 持久化。
### 首次运行引导 (Setup Wizard)
4 步引导流程,在 WebUI 中完成:
1. **欢迎** — 设置 WebUI 管理员密码、选择语言(中/英)、选择主题(深色/浅色)
2. **连接 TS 服务器** — 输入服务器地址、端口、昵称、默认频道,点击"测试连接"验证
3. **音乐账号(可选)** — 扫码/短信/Cookie 登录网易云或 QQ 音乐账号,跳过则只能播放免费歌曲
4. **完成** — Bot 已连接到 TS 服务器,跳转到主界面
### 端口配置
- WebUI 默认端口:`3000`(可在 config.json 中修改)
- 内嵌网易云 API 端口:`3001`(内部使用,不对外暴露)
- 内嵌 QQ 音乐 API 端口:`3002`(内部使用,不对外暴露)
## 7. 功能清单 (V1)
### 播放功能
- 搜索并播放歌曲(网易云/QQ音乐)
- 播放/暂停/停止/上一首/下一首
- 音量调节 (0-100)
- 4 种播放模式:顺序、单曲循环、随机、随机循环
### 播放列表
- 加载网易云/QQ音乐歌单
- 加载专辑
- 创建临时播放队列(添加/移除/清空/查看)
- 私人 FM 模式(网易云)
### 频道管理
- 自动跟随用户切换频道
- 无人时自动暂停
- 自动回到默认频道(可配置延迟)
- 手动移动到指定频道
### 社交互动
- 频道描述/Bot 头像更新为当前歌曲信息
- 投票切歌(频道内过半数同意)
- 频道内显示歌词片段
### 多实例
- 一个 WebUI 管理多个 Bot 实例
- 每个实例独立连接不同 TS 服务器或频道
- 独立的播放队列和配置
### 账号认证
- 扫码登录(网易云/QQ音乐)
- 短信登录(网易云)
- Cookie 手动导入
- Cookie 自动刷新
## 8. 未来演进
- 当音频编码出现性能瓶颈时,将 Opus 编码迁移到 Worker Threads
- 可扩展的音乐源插件系统(支持添加更多平台)
- 移动端 WebUI 适配