mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
commit
419911b78b
8 files changed
+201
-20
No files matched your search
+42
-5
@@ -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));
|
||||
}
|
||||
|
||||
@@ -268,6 +268,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}`;
|
||||
@@ -561,4 +563,8 @@ export class BotInstance extends EventEmitter {
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getIdentityExport(): string | undefined {
|
||||
return this.tsClient.getIdentityExport();
|
||||
}
|
||||
}
|
||||
+42
-9
@@ -143,12 +143,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<void> {
|
||||
@@ -163,6 +176,7 @@ export class BotManager {
|
||||
port: saved.serverPort,
|
||||
queryPort: proto === "ts6" ? 10080 : 10011,
|
||||
nickname: saved.nickname,
|
||||
identity: saved.identity || undefined,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
|
||||
@@ -178,18 +192,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(
|
||||
@@ -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 {
|
||||
for (const bot of this.bots.values()) {
|
||||
bot.disconnect();
|
||||
|
||||
+31
-6
@@ -28,6 +28,7 @@ export interface BotInstance {
|
||||
serverProtocol: string;
|
||||
/** API key for TS6 HTTP Query */
|
||||
ts6ApiKey: string;
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
export interface BotDatabase {
|
||||
@@ -40,6 +41,20 @@ export interface BotDatabase {
|
||||
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 {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS play_history (
|
||||
@@ -64,7 +79,8 @@ function initTables(db: Database.Database): void {
|
||||
channelPassword TEXT NOT NULL,
|
||||
autoStart INTEGER NOT NULL DEFAULT 0,
|
||||
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);
|
||||
db.pragma("journal_mode = WAL");
|
||||
initTables(db);
|
||||
migrateSchema(db);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
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(`
|
||||
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, serverProtocol, ts6ApiKey)
|
||||
VALUES (@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, @identity)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
serverAddress = excluded.serverAddress,
|
||||
@@ -95,7 +112,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
channelPassword = excluded.channelPassword,
|
||||
autoStart = excluded.autoStart,
|
||||
serverProtocol = excluded.serverProtocol,
|
||||
ts6ApiKey = excluded.ts6ApiKey
|
||||
ts6ApiKey = excluded.ts6ApiKey,
|
||||
identity = excluded.identity
|
||||
`);
|
||||
|
||||
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
||||
@@ -117,14 +135,21 @@ 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<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) {
|
||||
|
||||
@@ -77,6 +77,18 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
// Detect or use forced protocol
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
Reference in new issue
Block a user