diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2a01116 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [ + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "Bash(*)", + "WebFetch(*)", + "WebSearch(*)", + "Agent(*)", + "mcp__Claude_Preview__*", + "mcp__Claude_in_Chrome__*", + "mcp__scheduled-tasks__*" + ], + "deny": [ + "Bash(git push * main)", + "Bash(git push * master)", + "Bash(git push --force *)", + "Bash(rm -rf /)" + ] + } +} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..7e003df --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,25 @@ +{ + "permissions": { + "allow": [ + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "Bash(*)", + "WebFetch(*)", + "WebSearch(*)", + "Agent(*)", + "mcp__Claude_Preview__*", + "mcp__Claude_in_Chrome__*", + "mcp__scheduled-tasks__*", + "Bash(npx vitest:*)" + ], + "deny": [ + "Bash(git push * main)", + "Bash(git push * master)", + "Bash(git push --force *)", + "Bash(rm -rf /)" + ] + } +} diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-master.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-master.md new file mode 100644 index 0000000..7ed55a2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-master.md @@ -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. diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase1.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase1.md new file mode 100644 index 0000000..0e910f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase1.md @@ -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; + 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; + 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 & { 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" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase2.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase2.md new file mode 100644 index 0000000..a6eb151 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase2.md @@ -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 { + 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[] { + const entries = raw.split('|'); + return entries.map((entry) => { + const result: Record = {}; + 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[]; +} + +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[]; +} + +/** + * 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 { + 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 = {}): Promise { + 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 { + 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 | 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 { + 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) => { + 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): Promise { + return this.connection.send(command, params); + } + + async joinChannel(channelName: string, password?: string): Promise { + // 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 = { + 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 { + await this.joinChannel(channelNameOrId, password); + } + + async sendTextMessage(message: string, targetMode: number = 2): Promise { + // 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[]> { + 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)" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase3.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase3.md new file mode 100644 index 0000000..7ac12b7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase3.md @@ -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 | 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)" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase4.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase4.md new file mode 100644 index 0000000..93e2e8f --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase4.md @@ -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; + 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 { + 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; + + // Song operations + getSongUrl(songId: string): Promise; + getSongDetail(songId: string): Promise; + + // Playlist operations + getPlaylistSongs(playlistId: string): Promise; + getRecommendPlaylists(): Promise; + + // Album operations + getAlbumSongs(albumId: string): Promise; + + // Lyrics + getLyrics(songId: string): Promise; + + // Authentication + getQrCode(): Promise; + checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'>; + loginWithSms?(phone: string, code: string): Promise; + sendSmsCode?(phone: string): Promise; + setCookie(cookie: string): void; + getCookie(): string; + getAuthStatus(): Promise; + + // Personal FM (netease only) + getPersonalFm?(): Promise; +} +``` + +- [ ] **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(); + + // 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 { + return this.cookie ? { cookie: this.cookie } : {}; + } + + async search(query: string, limit = 20): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const res = await this.api.get('/captcha/sent', { + params: { phone }, + }); + return res.data?.code === 200; + } + + async loginWithSms(phone: string, code: string): Promise { + 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 { + 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 { + 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 { + return this.cookie ? { cookie: this.cookie } : {}; + } + + async search(query: string, limit = 20): Promise { + 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 { + const res = await this.api.get('/song/url', { + params: { id: songId, ...this.cookieParams }, + }); + return res.data?.data ?? null; + } + + async getSongDetail(songId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase5.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase5.md new file mode 100644 index 0000000..3077ac0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase5.md @@ -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; // 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 = {}, +): 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(); + 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(); + + 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 { + 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 { + 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 { + 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 { + if (!cmd.args) return 'Usage: !play '; + 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 { + if (!cmd.args) return 'Usage: !add '; + 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 '; + 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 = { + seq: PlayMode.Sequential, + loop: PlayMode.Loop, + random: PlayMode.Random, + rloop: PlayMode.RandomLoop, + }; + const mode = modeMap[cmd.args]; + if (!mode) return 'Usage: !mode '; + this.queue.setMode(mode); + this.emit('stateChange'); + return `Play mode set to: ${cmd.args}`; + } + + private async cmdPlaylist(cmd: ParsedCommand): Promise { + if (!cmd.args) return 'Usage: !playlist '; + 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 { + if (!cmd.args) return 'Usage: !album '; + 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 { + 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 { + 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 { + 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 { + if (!cmd.args) return 'Usage: !move '; + await this.tsClient.moveToChannel(cmd.args); + return `Moved to channel: ${cmd.args}`; + } + + private async cmdFollow(msg?: TS3TextMessage): Promise { + 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 — Search and play`, + `${p}play -q — Search from QQ Music`, + `${p}add — 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 — Play mode`, + `${p}playlist — Load playlist`, + `${p}album — 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(); + 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 { + 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 { + 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 { + 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 { + 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)" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase6.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase6.md new file mode 100644 index 0000000..780a301 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase6.md @@ -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; + 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 { + 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(); + + 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)" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase7.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase7.md new file mode 100644 index 0000000..948948b --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase7.md @@ -0,0 +1,1405 @@ +# TSMusicBot Phase 7: WebUI Frontend (Vue.js SPA) + +> **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 Vue.js SPA frontend with YesPlayMusic-inspired design. Covers all 6 pages (Home, Search, Playlist, Lyrics, History, Settings), the player bar, navbar, and WebSocket real-time updates. + +**Architecture:** Vue 3 + Vite SPA in `web/`. Pinia stores manage player state and WebSocket connection. SCSS variables handle theming (dark/light). The frontend communicates with the backend via REST API (`/api/*`) and WebSocket (`/ws`). + +**Tech Stack:** Vue 3, Vite 5, Pinia, Vue Router 4, SCSS, axios, node-vibrant, Iconify + +--- + +### Task 1: Scaffold Vue.js project + +**Files:** +- Create: `web/package.json` +- Create: `web/vite.config.ts` +- Create: `web/index.html` +- Create: `web/src/main.ts` +- Create: `web/src/App.vue` +- Create: `web/tsconfig.json` + +- [ ] **Step 1: Create Vue project in web/ directory** + +```bash +cd "C:/Users/saopig1/Music/teamspeak music bot" +mkdir -p web +cd web +npm init -y +npm install vue vue-router@4 pinia axios node-vibrant @iconify/vue +npm install -D vite @vitejs/plugin-vue typescript sass vue-tsc +``` + +- [ ] **Step 2: Create web/vite.config.ts** + +```typescript +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3000', + '/ws': { + target: 'ws://localhost:3000', + ws: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +}); +``` + +- [ ] **Step 3: Create web/tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Step 4: Create web/index.html** + +```html + + + + + + TSMusicBot + + + + +
+ + + +``` + +- [ ] **Step 5: Create web/src/main.ts** + +```typescript +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router/index.js'; +import './styles/global.scss'; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount('#app'); +``` + +- [ ] **Step 6: Create web/src/App.vue** + +```vue + + + + + +``` + +- [ ] **Step 7: Commit** + +```bash +cd "C:/Users/saopig1/Music/teamspeak music bot" +git add web/ +git commit -m "feat: scaffold Vue.js frontend project with Vite, Pinia, and Vue Router" +``` + +--- + +### Task 2: Global styles and theming + +**Files:** +- Create: `web/src/styles/variables.scss` +- Create: `web/src/styles/global.scss` + +- [ ] **Step 1: Create variables.scss** + +```scss +// YesPlayMusic-inspired design tokens + +:root { + // Fonts + --font-primary: 'Barlow', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; + + // Spacing + --navbar-height: 56px; + --player-height: 56px; + + // Radius + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + + // Transitions + --transition-fast: 0.2s ease; + --transition-normal: 0.3s ease; + + // Accent + --color-primary: #335eea; + --color-primary-bg: #eaeffd; +} + +[data-theme='dark'] { + --bg-primary: #222222; + --bg-secondary: #323232; + --bg-card: rgba(255, 255, 255, 0.04); + --bg-navbar: rgba(34, 34, 34, 0.86); + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.58); + --text-tertiary: rgba(255, 255, 255, 0.38); + --border-color: rgba(255, 255, 255, 0.06); + --hover-bg: rgba(255, 255, 255, 0.08); +} + +[data-theme='light'] { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f7; + --bg-card: rgba(0, 0, 0, 0.02); + --bg-navbar: rgba(255, 255, 255, 0.86); + --text-primary: #000000; + --text-secondary: rgba(0, 0, 0, 0.58); + --text-tertiary: rgba(0, 0, 0, 0.38); + --border-color: rgba(0, 0, 0, 0.06); + --hover-bg: rgba(0, 0, 0, 0.04); +} +``` + +- [ ] **Step 2: Create global.scss** + +```scss +@use 'variables'; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; +} + +body { + font-family: var(--font-primary); + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + text-decoration: none; + color: inherit; +} + +button { + border: none; + background: none; + cursor: pointer; + font-family: inherit; + color: inherit; +} + +// Scrollbar +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 4px; +} + +// Shared utility classes +.frosted-glass { + background: var(--bg-navbar); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); +} + +.hover-scale { + transition: transform var(--transition-fast); + + &:hover { + transform: scale(1.04); + } + + &:active { + transform: scale(0.96); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add web/src/styles/ +git commit -m "feat: add YesPlayMusic-inspired SCSS theme with dark/light mode" +``` + +--- + +### Task 3: Pinia stores (player + WebSocket) + +**Files:** +- Create: `web/src/stores/player.ts` +- Create: `web/src/composables/useWebSocket.ts` + +- [ ] **Step 1: Create player store** + +Create `web/src/stores/player.ts`: + +```typescript +import { defineStore } from 'pinia'; +import axios from 'axios'; + +export interface Song { + id: string; + name: string; + artist: string; + album: string; + duration: number; + coverUrl: string; + platform: 'netease' | 'qq'; +} + +export interface BotStatus { + id: string; + name: string; + connected: boolean; + playing: boolean; + paused: boolean; + currentSong: Song | null; + queueSize: number; + volume: number; + playMode: string; +} + +export const usePlayerStore = defineStore('player', { + state: () => ({ + bots: [] as BotStatus[], + activeBotId: null as string | null, + queue: [] as Song[], + theme: 'dark' as 'dark' | 'light', + }), + + getters: { + activeBot(): BotStatus | null { + return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null; + }, + currentSong(): Song | null { + return this.activeBot?.currentSong ?? null; + }, + isPlaying(): boolean { + return this.activeBot?.playing ?? false; + }, + isPaused(): boolean { + return this.activeBot?.paused ?? false; + }, + }, + + actions: { + setActiveBotId(id: string) { + this.activeBotId = id; + }, + + updateBotStatus(botId: string, status: BotStatus) { + const index = this.bots.findIndex((b) => b.id === botId); + if (index >= 0) { + this.bots[index] = status; + } else { + this.bots.push(status); + } + }, + + removeBotStatus(botId: string) { + this.bots = this.bots.filter((b) => b.id !== botId); + }, + + setQueue(queue: Song[]) { + this.queue = queue; + }, + + toggleTheme() { + this.theme = this.theme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('theme', this.theme); + }, + + loadTheme() { + const saved = localStorage.getItem('theme') as 'dark' | 'light' | null; + if (saved) this.theme = saved; + }, + + async fetchBots() { + const res = await axios.get('/api/bot'); + this.bots = res.data.bots; + if (!this.activeBotId && this.bots.length > 0) { + this.activeBotId = this.bots[0].id; + } + }, + + async play(query: string, platform = 'netease') { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform }); + }, + + async addToQueue(query: string, platform = 'netease') { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform }); + }, + + async pause() { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/pause`); + }, + + async resume() { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/resume`); + }, + + async next() { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/next`); + }, + + async prev() { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/prev`); + }, + + async stop() { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/stop`); + }, + + async setVolume(volume: number) { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/volume`, { volume }); + }, + + async setMode(mode: string) { + if (!this.activeBotId) return; + await axios.post(`/api/player/${this.activeBotId}/mode`, { mode }); + }, + }, +}); +``` + +- [ ] **Step 2: Create WebSocket composable** + +Create `web/src/composables/useWebSocket.ts`: + +```typescript +import { ref, onUnmounted } from 'vue'; +import { usePlayerStore } from '../stores/player.js'; + +export function useWebSocket() { + const connected = ref(false); + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + + function connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${window.location.host}/ws`; + + ws = new WebSocket(url); + + ws.onopen = () => { + connected.value = true; + console.log('WebSocket connected'); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + const store = usePlayerStore(); + + switch (data.type) { + case 'init': + for (const bot of data.bots) { + store.updateBotStatus(bot.id, bot); + } + break; + case 'stateChange': + store.updateBotStatus(data.botId, data.status); + if (data.queue) store.setQueue(data.queue); + break; + case 'botConnected': + store.updateBotStatus(data.botId, data.status); + break; + case 'botDisconnected': + store.removeBotStatus(data.botId); + break; + } + }; + + ws.onclose = () => { + connected.value = false; + // Reconnect after 3 seconds + reconnectTimer = setTimeout(connect, 3000); + }; + + ws.onerror = () => { + ws?.close(); + }; + } + + function disconnect() { + if (reconnectTimer) clearTimeout(reconnectTimer); + ws?.close(); + } + + onUnmounted(disconnect); + + return { connected, connect, disconnect }; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add web/src/stores/ web/src/composables/ +git commit -m "feat: add Pinia player store and WebSocket composable" +``` + +--- + +### Task 4: Router setup + +**Files:** +- Create: `web/src/router/index.ts` + +- [ ] **Step 1: Create router** + +Create `web/src/router/index.ts`: + +```typescript +import { createRouter, createWebHistory } from 'vue-router'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + component: () => import('../views/Home.vue'), + }, + { + path: '/search', + name: 'search', + component: () => import('../views/Search.vue'), + }, + { + path: '/playlist/:id', + name: 'playlist', + component: () => import('../views/Playlist.vue'), + }, + { + path: '/lyrics', + name: 'lyrics', + component: () => import('../views/Lyrics.vue'), + }, + { + path: '/history', + name: 'history', + component: () => import('../views/History.vue'), + }, + { + path: '/settings', + name: 'settings', + component: () => import('../views/Settings.vue'), + }, + ], +}); + +export default router; +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/router/ +git commit -m "feat: add Vue Router with all page routes" +``` + +--- + +### Task 5: Navbar component + +**Files:** +- Create: `web/src/components/Navbar.vue` + +- [ ] **Step 1: Create Navbar** + +Create `web/src/components/Navbar.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/components/Navbar.vue +git commit -m "feat: add frosted-glass Navbar component" +``` + +--- + +### Task 6: Player bar component + +**Files:** +- Create: `web/src/components/Player.vue` + +- [ ] **Step 1: Create Player** + +Create `web/src/components/Player.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/components/Player.vue +git commit -m "feat: add frosted-glass Player bar with playback controls and volume" +``` + +--- + +### Task 7: CoverArt component (with colored shadow) + +**Files:** +- Create: `web/src/components/CoverArt.vue` + +- [ ] **Step 1: Create CoverArt** + +Create `web/src/components/CoverArt.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/components/CoverArt.vue +git commit -m "feat: add CoverArt component with YesPlayMusic-style colored shadow" +``` + +--- + +### Task 8: Home page + +**Files:** +- Create: `web/src/views/Home.vue` + +- [ ] **Step 1: Create Home view** + +Create `web/src/views/Home.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/views/Home.vue +git commit -m "feat: add Home page with bot selector, search bar, now playing, and playlists" +``` + +--- + +### Task 9: Search, Playlist, Lyrics, History, Settings pages + +**Files:** +- Create: `web/src/views/Search.vue` +- Create: `web/src/views/Playlist.vue` +- Create: `web/src/views/Lyrics.vue` +- Create: `web/src/views/History.vue` +- Create: `web/src/views/Settings.vue` +- Create: `web/src/components/SongCard.vue` +- Create: `web/src/components/Queue.vue` + +These pages follow the same patterns established in Task 8. Each page: +- Uses the Pinia store for state +- Calls REST API endpoints for data +- Uses shared components (CoverArt, SongCard) +- Follows the YesPlayMusic design tokens (SCSS variables, frosted glass, hover-scale, etc.) + +Due to the size, each view file should be implemented following these specs: + +- [ ] **Step 1: Create SongCard component** + +Create `web/src/components/SongCard.vue` — a reusable song row with cover, name, artist, duration, and play/add buttons. Used in search results, playlist detail, and queue. + +```vue + + + + + +``` + +- [ ] **Step 2: Create Search view** + +Create `web/src/views/Search.vue` — search input, platform toggle (netease/qq), results as SongCard list. + +- [ ] **Step 3: Create Playlist view** + +Create `web/src/views/Playlist.vue` — hero header with cover art, playlist name, play all button, song list. + +- [ ] **Step 4: Create Lyrics view** + +Create `web/src/views/Lyrics.vue` — full-screen with dynamic blurred album art background, scrolling lyrics with active line highlighting. Uses `node-vibrant` for color extraction. + +- [ ] **Step 5: Create History view** + +Create `web/src/views/History.vue` — play history list from `/api/player/:botId/history`. + +- [ ] **Step 6: Create Settings view** + +Create `web/src/views/Settings.vue` — bot instance management (create/delete/start/stop), TS server connection config, music account login (QR code display, SMS form, cookie paste), command prefix, theme toggle. + +- [ ] **Step 7: Create Queue component** + +Create `web/src/components/Queue.vue` — sidebar queue panel showing current queue with drag-to-reorder and remove buttons. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/views/ web/src/components/ +git commit -m "feat: add all page views (Search, Playlist, Lyrics, History, Settings) and shared components" +``` + +--- + +### Task 10: Build and verify + +- [ ] **Step 1: Build frontend** + +```bash +cd web && npm run build +``` + +Expected: `web/dist/` is created with `index.html` and JS/CSS assets. + +- [ ] **Step 2: Add frontend build script to root package.json** + +Add to root `package.json` scripts: + +```json +{ + "scripts": { + "build:web": "cd web && npm run build", + "build": "tsc && npm run build:web" + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd "C:/Users/saopig1/Music/teamspeak music bot" +git add -A +git commit -m "chore: Phase 7 complete — Vue.js WebUI with YesPlayMusic-inspired design" +``` diff --git a/docs/superpowers/plans/2026-03-29-tsmusicbot-phase8.md b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase8.md new file mode 100644 index 0000000..d7f5b74 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-tsmusicbot-phase8.md @@ -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 < +
+ +
+
+
{{ currentStep > i ? '✓' : i + 1 }}
+
{{ label }}
+
+
+ + +
+

欢迎使用 TSMusicBot

+

请设置管理员密码以保护你的 WebUI

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+

连接 TeamSpeak 服务器

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

登录音乐账号 (可选)

+

登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲

+
+ + + +
+
+ + +
+

设置完成!

+

TSMusicBot 已准备就绪

+ +
+
+ + + + + +``` + +- [ ] **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" +``` diff --git a/docs/superpowers/specs/2026-03-29-tsmusicbot-design.md b/docs/superpowers/specs/2026-03-29-tsmusicbot-design.md new file mode 100644 index 0000000..2f26c3b --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-tsmusicbot-design.md @@ -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 ` | 切换播放模式:顺序/单曲循环/随机/随机循环 | + +### 歌单 & 专辑 + +| 命令 | 说明 | +|------|------| +| `!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 适配