Merge origin/main into dev: sync bug fixes from main

Merges 4 PRs from main:
- PR#15: Fix ffmpeg spawn failure causing infinite retry loop
- PR#16: Fix Docker restart crash (respect autoStart flag, persist identity)
- PR#17: Add missing "play" script to package.json
- PR#18: Add detailed update/upgrade instructions to README

Conflict resolution in src/data/database.ts:
- Combined dev's serverProtocol/ts6ApiKey fields with main's identity field
- Extended migrateSchema() to also migrate serverProtocol and ts6ApiKey columns
- Updated SQL schema and upsert query to include all three new columns
This commit is contained in:
saopig1 committed 2026-04-08 15:48:40 +08:00
commit 419911b78b
8 files changed
+201 -20

No files matched your search

+64
View File
@@ -161,6 +161,70 @@ sudo ./scripts/install.sh
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。 自动安装 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,所有数据会自动保留。
## 使用说明 ## 使用说明
### 首次配置 ### 首次配置
+1
View File
@@ -8,6 +8,7 @@
"build": "tsc && npm run build:web", "build": "tsc && npm run build:web",
"build:web": "cd web && npm run build", "build:web": "cd web && npm run build",
"start": "node dist/index.js", "start": "node dist/index.js",
"play": "node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
+42 -5
View File
@@ -37,15 +37,23 @@ function ffmpegWorks(bin: string): boolean {
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */ /** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
const resolvedFfmpeg: string = (() => { const resolvedFfmpeg: string = (() => {
if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { // On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
return ffmpegPath; // 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 // Fall back to system ffmpeg
if (ffmpegWorks("ffmpeg")) { if (ffmpegWorks("ffmpeg")) {
return "ffmpeg"; return "ffmpeg";
} }
// Last resort: return whatever we have, will fail at runtime with clear error // Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return ffmpegPath ?? "ffmpeg"; return "ffmpeg";
})(); })();
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */ /** 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_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private ffmpegPaused = false; 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) { constructor(logger: Logger) {
super(); super();
@@ -94,6 +105,18 @@ export class AudioPlayer extends EventEmitter {
this.seekOffset = seekSeconds; this.seekOffset = seekSeconds;
this.framesPlayed = 0; this.framesPlayed = 0;
this.ffmpegPaused = false; 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"); 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.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error"); this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) { if (this.sessionId === playSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err); this.emit("error", err);
} }
}); });
@@ -210,7 +235,13 @@ export class AudioPlayer extends EventEmitter {
this.frameLoopRunning = false; this.frameLoopRunning = false;
if (this.state !== "idle") { if (this.state !== "idle") {
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; return;
} }
@@ -303,12 +334,18 @@ export class AudioPlayer extends EventEmitter {
} }
this.pcmBuffer = Buffer.alloc(0); this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false; this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle"; this.state = "idle";
this.currentUrl = ""; this.currentUrl = "";
this.seekOffset = 0; this.seekOffset = 0;
this.framesPlayed = 0; this.framesPlayed = 0;
} }
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void { setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol)); this.volume = Math.max(0, Math.min(100, vol));
} }
+6
View File
@@ -268,6 +268,8 @@ export class BotInstance extends EventEmitter {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
this.queue.play(); this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
if (!ok) return `Cannot play: ${song.name}`; if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`; return `Now playing: ${song.name} - ${song.artist}`;
@@ -561,4 +563,8 @@ export class BotInstance extends EventEmitter {
isConnected(): boolean { isConnected(): boolean {
return this.connected; return this.connected;
} }
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
} }
+42 -9
View File
@@ -143,12 +143,25 @@ export class BotManager {
const bot = this.bots.get(id); const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`); if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect(); 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 { stopBot(id: string): void {
const bot = this.bots.get(id); const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`); if (!bot) throw new Error(`Bot ${id} not found`);
bot.disconnect(); 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<void> { async loadSavedBots(): Promise<void> {
@@ -163,6 +176,7 @@ export class BotManager {
port: saved.serverPort, port: saved.serverPort,
queryPort: proto === "ts6" ? 10080 : 10011, queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname, nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined, defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined, channelPassword: saved.channelPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined, serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
@@ -178,18 +192,30 @@ export class BotManager {
this.bots.set(saved.id, bot); this.bots.set(saved.id, bot);
// Auto-connect in background (non-blocking, won't affect other bots) // Only auto-connect bots that have autoStart enabled
bot.connect().then(() => { 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( this.logger.info(
{ botId: saved.id, name: saved.name }, { 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( this.logger.info(
@@ -198,6 +224,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 { shutdown(): void {
for (const bot of this.bots.values()) { for (const bot of this.bots.values()) {
bot.disconnect(); bot.disconnect();
+31 -6
View File
@@ -28,6 +28,7 @@ export interface BotInstance {
serverProtocol: string; serverProtocol: string;
/** API key for TS6 HTTP Query */ /** API key for TS6 HTTP Query */
ts6ApiKey: string; ts6ApiKey: string;
identity?: string;
} }
export interface BotDatabase { export interface BotDatabase {
@@ -40,6 +41,20 @@ export interface BotDatabase {
close(): void; close(): void;
} }
function migrateSchema(db: Database.Database): void {
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
const names = columns.map((c) => c.name);
if (!names.includes("identity")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
if (!names.includes("serverProtocol")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("ts6ApiKey")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
}
}
function initTables(db: Database.Database): void { function initTables(db: Database.Database): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS play_history ( CREATE TABLE IF NOT EXISTS play_history (
@@ -64,7 +79,8 @@ function initTables(db: Database.Database): void {
channelPassword TEXT NOT NULL, channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0, autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '', serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '' ts6ApiKey TEXT NOT NULL DEFAULT '',
identity TEXT
); );
`); `);
} }
@@ -73,6 +89,7 @@ export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
initTables(db); initTables(db);
migrateSchema(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -84,8 +101,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`); `);
const upsertInstance = db.prepare(` const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey) INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey) VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @identity)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
serverAddress = excluded.serverAddress, serverAddress = excluded.serverAddress,
@@ -95,7 +112,8 @@ export function createDatabase(dbPath: string): BotDatabase {
channelPassword = excluded.channelPassword, channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart, autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol, serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey ts6ApiKey = excluded.ts6ApiKey,
identity = excluded.identity
`); `);
const selectInstances = db.prepare(`SELECT * FROM bot_instances`); const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
@@ -117,14 +135,21 @@ export function createDatabase(dbPath: string): BotDatabase {
upsertInstance.run({ upsertInstance.run({
...instance, ...instance,
autoStart: instance.autoStart ? 1 : 0, autoStart: instance.autoStart ? 1 : 0,
identity: instance.identity ?? null,
}); });
}, },
getBotInstances() { getBotInstances() {
const rows = selectInstances.all() as Array< const rows = selectInstances.all() as Array<
Omit<BotInstance, "autoStart"> & { autoStart: number } Omit<BotInstance, "autoStart" | "identity"> & { autoStart: number; identity: string | null }
>; >;
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 })); return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
identity: r.identity ?? undefined,
}));
}, },
deleteBotInstance(id) { deleteBotInstance(id) {
+12
View File
@@ -77,6 +77,18 @@ export class TS3Client extends EventEmitter {
} }
async connect(): Promise<void> { async connect(): Promise<void> {
// 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}`; const addr = `${this.options.host}:${this.options.port}`;
// Detect or use forced protocol // Detect or use forced protocol
+3
View File
@@ -154,6 +154,7 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.getPlayer().stop(); // Stop current playback first bot.getPlayer().stop(); // Stop current playback first
bot.getPlayer().resetFailures(); // Reset on user-initiated play
const song = queue.playAt(index); const song = queue.playAt(index);
if (!song) { if (!song) {
res.status(400).json({ error: "Invalid queue index" }); res.status(400).json({ error: "Invalid queue index" });
@@ -200,6 +201,7 @@ export function createPlayerRouter(
// Stop current playback // Stop current playback
bot.getPlayer().stop(); bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId); const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) { if (songs.length === 0) {
@@ -256,6 +258,7 @@ export function createPlayerRouter(
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
queue.play(); queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) { if (!ok) {
res.json({ message: `Cannot play: ${song.name}` }); res.json({ message: `Cannot play: ${song.name}` });