From d1fc7baa5dcc82b4db31c2afa878472a9fe33bde Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 12:03:33 +0000 Subject: [PATCH 1/4] Fix ffmpeg spawn failure causing infinite retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues fixed: 1. Cross-platform ffmpeg-static resolution: skip Windows .exe paths on Linux and always fall back to "ffmpeg" instead of a known-bad path 2. Prevent trackEnd cascade when ffmpeg spawn fails — track consecutive failures and stop after 3, suppressing trackEnd on spawn errors https://claude.ai/code/session_013vHRF8BbDGjZLqheFS85Q6 --- src/audio/player.ts | 47 ++++++++++++++++++++++++++++++++++++++----- src/bot/instance.ts | 2 ++ src/web/api/player.ts | 3 +++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/audio/player.ts b/src/audio/player.ts index 03be44f..f0bcb53 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -37,15 +37,23 @@ function ffmpegWorks(bin: string): boolean { /** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */ const resolvedFfmpeg: string = (() => { - if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { - return ffmpegPath; + // On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS + // it may return a Windows .exe path if node_modules was copied cross-platform. + const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false; + const onWindows = process.platform === "win32"; + + // Only try bundled binary if platform matches + if (ffmpegPath && (onWindows === isWinPath)) { + if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { + return ffmpegPath; + } } // Fall back to system ffmpeg if (ffmpegWorks("ffmpeg")) { return "ffmpeg"; } - // Last resort: return whatever we have, will fail at runtime with clear error - return ffmpegPath ?? "ffmpeg"; + // Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path + return "ffmpeg"; })(); /** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */ @@ -79,6 +87,9 @@ export class AudioPlayer extends EventEmitter { private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s private ffmpegPaused = false; + private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade) + private consecutiveFailures = 0; + private static readonly MAX_CONSECUTIVE_FAILURES = 3; constructor(logger: Logger) { super(); @@ -94,6 +105,18 @@ export class AudioPlayer extends EventEmitter { this.seekOffset = seekSeconds; this.framesPlayed = 0; this.ffmpegPaused = false; + this.spawnFailed = false; + + // Prevent rapid-fire spawn attempts when ffmpeg is broken + if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) { + this.logger.error( + { failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() }, + "Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback." + ); + this.state = "idle"; + this.emit("error", new Error("ffmpeg unavailable after repeated failures")); + return; + } this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback"); @@ -160,6 +183,8 @@ export class AudioPlayer extends EventEmitter { this.ffmpeg.on("error", (err) => { this.logger.error({ err }, "FFmpeg error"); if (this.sessionId === playSessionId) { + this.spawnFailed = true; + this.consecutiveFailures++; this.emit("error", err); } }); @@ -210,7 +235,13 @@ export class AudioPlayer extends EventEmitter { this.frameLoopRunning = false; if (this.state !== "idle") { this.state = "idle"; - this.emit("trackEnd"); + // Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade + if (this.spawnFailed) { + this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure"); + } else { + this.consecutiveFailures = 0; // Reset on successful track completion + this.emit("trackEnd"); + } } return; } @@ -303,12 +334,18 @@ export class AudioPlayer extends EventEmitter { } this.pcmBuffer = Buffer.alloc(0); this.ffmpegPaused = false; + this.spawnFailed = false; this.state = "idle"; this.currentUrl = ""; this.seekOffset = 0; this.framesPlayed = 0; } + /** Reset the consecutive failure counter (e.g. after user action) */ + resetFailures(): void { + this.consecutiveFailures = 0; + } + setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); } diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0058824..23261ef 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -263,6 +263,8 @@ export class BotInstance extends EventEmitter { this.queue.add({ ...song, platform: provider.platform }); this.queue.play(); + // Reset failure counter on user-initiated play + this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); if (!ok) return `Cannot play: ${song.name}`; return `Now playing: ${song.name} - ${song.artist}`; diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 305c133..bd82b61 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -154,6 +154,7 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); bot.getPlayer().stop(); // Stop current playback first + bot.getPlayer().resetFailures(); // Reset on user-initiated play const song = queue.playAt(index); if (!song) { res.status(400).json({ error: "Invalid queue index" }); @@ -200,6 +201,7 @@ export function createPlayerRouter( // Stop current playback bot.getPlayer().stop(); + bot.getPlayer().resetFailures(); const songs = await provider.getPlaylistSongs(playlistId); if (songs.length === 0) { @@ -256,6 +258,7 @@ export function createPlayerRouter( queue.add({ ...song, platform: provider.platform }); queue.play(); + bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { res.json({ message: `Cannot play: ${song.name}` }); From 2559701e55b554fc2c996a11c56c29b96260c4e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 12:15:39 +0000 Subject: [PATCH 2/4] Fix Docker restart crash: respect autoStart flag and persist identity Root cause: on Docker restart, loadSavedBots() unconditionally connected ALL saved bots regardless of autoStart flag, causing a rapid connect/disconnect loop. Additionally, bot identities were regenerated on every restart, causing TS server conflicts with stale sessions. Changes: - loadSavedBots() now only auto-connects bots with autoStart=true - startBot/stopBot persist autoStart state so restart behavior matches user intent - Bot TS3 identity is persisted to database and reused across restarts - Database schema migrated to include identity column - TS3Client.connect() cleans up existing connection before reconnecting - Stagger bot connections by 1s to avoid overwhelming the TS server https://claude.ai/code/session_01L2kEV2M1QFWMCyPtLU5LgC --- src/bot/instance.ts | 4 +++ src/bot/manager.ts | 51 ++++++++++++++++++++++++++++++++------- src/data/database.ts | 30 ++++++++++++++++++----- src/ts-protocol/client.ts | 12 +++++++++ 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 23261ef..a44bfb2 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -553,4 +553,8 @@ export class BotInstance extends EventEmitter { isConnected(): boolean { return this.connected; } + + getIdentityExport(): string | undefined { + return this.tsClient.getIdentityExport(); + } } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 4dab98a..9043f93 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -131,12 +131,25 @@ export class BotManager { const bot = this.bots.get(id); if (!bot) throw new Error(`Bot ${id} not found`); await bot.connect(); + + // Mark as autoStart so it reconnects on Docker restart, and persist identity + const saved = this.database.getBotInstances().find((i) => i.id === id); + if (saved) { + this.database.saveBotInstance({ ...saved, autoStart: true }); + this.persistBotIdentity(saved, bot); + } } stopBot(id: string): void { const bot = this.bots.get(id); if (!bot) throw new Error(`Bot ${id} not found`); bot.disconnect(); + + // Mark as not autoStart so it stays stopped on Docker restart + const saved = this.database.getBotInstances().find((i) => i.id === id); + if (saved) { + this.database.saveBotInstance({ ...saved, autoStart: false }); + } } async loadSavedBots(): Promise { @@ -150,6 +163,7 @@ export class BotManager { port: saved.serverPort, queryPort: 10011, nickname: saved.nickname, + identity: saved.identity || undefined, defaultChannel: saved.defaultChannel || undefined, channelPassword: saved.channelPassword || undefined, }, @@ -163,18 +177,30 @@ export class BotManager { this.bots.set(saved.id, bot); - // Auto-connect in background (non-blocking, won't affect other bots) - bot.connect().then(() => { + // Only auto-connect bots that have autoStart enabled + if (saved.autoStart) { + bot.connect().then(() => { + // Persist identity after successful connection for future restarts + this.persistBotIdentity(saved, bot); + this.logger.info( + { botId: saved.id, name: saved.name }, + "Auto-connected saved bot" + ); + }).catch((err) => { + this.logger.error( + { err, botId: saved.id, name: saved.name }, + "Failed to auto-connect bot (start manually from Settings)" + ); + }); + + // Stagger connections to avoid overwhelming the TS server + await new Promise((resolve) => setTimeout(resolve, 1000)); + } else { this.logger.info( { botId: saved.id, name: saved.name }, - "Auto-connected saved bot" + "Loaded bot (autoStart disabled, not connecting)" ); - }).catch((err) => { - this.logger.error( - { err, botId: saved.id, name: saved.name }, - "Failed to auto-connect bot (start manually from Settings)" - ); - }); + } } this.logger.info( @@ -183,6 +209,13 @@ export class BotManager { ); } + private persistBotIdentity(saved: import("../data/database.js").BotInstance, bot: BotInstance): void { + const identity = bot.getIdentityExport(); + if (identity && identity !== saved.identity) { + this.database.saveBotInstance({ ...saved, identity }); + } + } + shutdown(): void { for (const bot of this.bots.values()) { bot.disconnect(); diff --git a/src/data/database.ts b/src/data/database.ts index 8f043ba..3ece408 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -24,6 +24,7 @@ export interface BotInstance { defaultChannel: string; channelPassword: string; autoStart: boolean; + identity?: string; } export interface BotDatabase { @@ -36,6 +37,15 @@ export interface BotDatabase { close(): void; } +function migrateSchema(db: Database.Database): void { + // Add identity column if it doesn't exist (migration for existing databases) + const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>; + const hasIdentity = columns.some((c) => c.name === "identity"); + if (!hasIdentity) { + db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT"); + } +} + function initTables(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS play_history ( @@ -58,7 +68,8 @@ function initTables(db: Database.Database): void { nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL, channelPassword TEXT NOT NULL, - autoStart INTEGER NOT NULL DEFAULT 0 + autoStart INTEGER NOT NULL DEFAULT 0, + identity TEXT ); `); } @@ -67,6 +78,7 @@ export function createDatabase(dbPath: string): BotDatabase { const db = new Database(dbPath); db.pragma("journal_mode = WAL"); initTables(db); + migrateSchema(db); const insertHistory = db.prepare(` INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) @@ -78,8 +90,8 @@ export function createDatabase(dbPath: string): BotDatabase { `); const upsertInstance = db.prepare(` - INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart) - VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart) + INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity) + VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity) ON CONFLICT(id) DO UPDATE SET name = excluded.name, serverAddress = excluded.serverAddress, @@ -87,7 +99,8 @@ export function createDatabase(dbPath: string): BotDatabase { nickname = excluded.nickname, defaultChannel = excluded.defaultChannel, channelPassword = excluded.channelPassword, - autoStart = excluded.autoStart + autoStart = excluded.autoStart, + identity = excluded.identity `); const selectInstances = db.prepare(`SELECT * FROM bot_instances`); @@ -109,14 +122,19 @@ export function createDatabase(dbPath: string): BotDatabase { upsertInstance.run({ ...instance, autoStart: instance.autoStart ? 1 : 0, + identity: instance.identity ?? null, }); }, getBotInstances() { const rows = selectInstances.all() as Array< - Omit & { autoStart: number } + Omit & { autoStart: number; identity: string | null } >; - return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 })); + return rows.map((r) => ({ + ...r, + autoStart: r.autoStart === 1, + identity: r.identity ?? undefined, + })); }, deleteBotInstance(id) { diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index 63e5c04..a3bb428 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -53,6 +53,18 @@ export class TS3Client extends EventEmitter { } async connect(): Promise { + // Clean up any existing connection before creating a new one + if (this.client) { + this.logger.info("Cleaning up previous connection before reconnecting"); + try { + await this.client.disconnect(); + } catch { + // Ignore errors during cleanup + } + this.client = null; + this.clientId = 0; + } + const addr = `${this.options.host}:${this.options.port}`; this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)"); From d5a7351be7bdff0e354eb6ddd246660e47d0c3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 12:44:18 +0000 Subject: [PATCH 3/4] Add missing "play" script to package.json Adds a "play" script as an alias for "start" (both run `node dist/index.js`). This resolves confusion where users might try `npm run play` based on the bot's music-playing nature. Fixes #11 https://claude.ai/code/session_01GC6qsKsmiroNkruLENDhPq --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 05c7fdc..694d5fc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc && npm run build:web", "build:web": "cd web && npm run build", "start": "node dist/index.js", + "play": "node dist/index.js", "test": "vitest run", "test:watch": "vitest" }, From 5a7112e28896877f4d784d294a05eb32b2d9ea1c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 12:51:41 +0000 Subject: [PATCH 4/4] Add detailed update/upgrade instructions to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "更新升级" section covering update steps for all deployment methods: Windows, manual install, Docker, and Linux systemd. https://claude.ai/code/session_01BrEtGk284qmnMwzi6zcEpD --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index 7b0eb60..3dded19 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,70 @@ sudo ./scripts/install.sh 自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。 +## 更新升级 + +### Windows 用户 + +``` +1. 双击 scripts\stop.bat 停止运行中的机器人(或手动关闭窗口) +2. 在项目目录打开命令行,执行 git pull +3. 双击 scripts\setup.bat 重新安装依赖并构建 +4. 双击 scripts\start.bat 启动 +``` + +### 手动安装用户(所有系统) + +```bash +# 停止当前运行的机器人(Ctrl+C 或 kill 进程) + +# 拉取最新代码 +git pull + +# 重新安装依赖(如有新增依赖) +npm install +cd web && npm install && cd .. + +# 重新构建 +npm run build + +# 启动 +npm start +``` + +### Docker 用户 + +```bash +cd scripts/docker + +# 拉取最新代码 +git pull + +# 重新构建并启动(数据自动保留) +docker-compose up -d --build +``` + +> 数据(数据库、Cookie、日志)保存在 Docker 命名卷 `tsmusicbot-data` 中,更新不会丢失。 + +### Linux systemd 用户 + +```bash +# 停止服务 +sudo systemctl stop tsmusicbot + +# 拉取最新代码 +git pull + +# 重新安装依赖并构建 +npm install +cd web && npm install && cd .. +npm run build + +# 重新启动服务 +sudo systemctl start tsmusicbot +``` + +> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。 + ## 使用说明 ### 首次配置