mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c50c6f29 | ||
|
|
a8eb1bebd9 | ||
|
|
5a7112e288 | ||
|
|
bbc776292b | ||
|
|
d5a7351be7 | ||
|
|
5f35c32a22 | ||
|
|
2559701e55 | ||
|
|
b97f4a1d86 | ||
|
|
d1fc7baa5d | ||
|
|
01940e74dd | ||
|
|
1c88dd7a35 |
No files matched your search
@@ -124,6 +124,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,所有数据会自动保留。
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
### 首次配置
|
### 首次配置
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
+50
-5
@@ -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");
|
||||||
|
|
||||||
@@ -128,6 +151,14 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
||||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
|
||||||
|
// Prevent stream errors from crashing the process
|
||||||
|
this.ffmpeg.stdout!.on("error", (err) => {
|
||||||
|
this.logger.warn({ err }, "FFmpeg stdout error");
|
||||||
|
});
|
||||||
|
this.ffmpeg.stderr!.on("error", (err) => {
|
||||||
|
this.logger.warn({ err }, "FFmpeg stderr error");
|
||||||
|
});
|
||||||
|
|
||||||
let gotFirstData = false;
|
let gotFirstData = false;
|
||||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||||
if (!gotFirstData) {
|
if (!gotFirstData) {
|
||||||
@@ -152,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -202,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;
|
||||||
}
|
}
|
||||||
@@ -295,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));
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-30
@@ -99,7 +99,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private setupTsEvents(): void {
|
private setupTsEvents(): void {
|
||||||
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
|
||||||
this.handleTextMessage(msg);
|
this.handleTextMessage(msg).catch((err) => {
|
||||||
|
this.logger.error({ err }, "Unhandled error in text message handler");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.tsClient.on("disconnected", () => {
|
this.tsClient.on("disconnected", () => {
|
||||||
@@ -144,13 +146,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (response) {
|
if (response) {
|
||||||
await this.tsClient.sendTextMessage(response);
|
await this.tsClient.sendTextMessage(response);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
this.logger.error({ err, command: parsed.name }, "Command execution error");
|
||||||
const friendlyMsg = err?.code === "ECONNREFUSED"
|
|
||||||
? "Error: Music API server is not reachable. Please check if the service is running."
|
|
||||||
: `Error: ${(err as Error).message}`;
|
|
||||||
try {
|
try {
|
||||||
await this.tsClient.sendTextMessage(friendlyMsg);
|
await this.tsClient.sendTextMessage(
|
||||||
|
`Error: ${(err as Error).message}`
|
||||||
|
);
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
|
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
|
||||||
}
|
}
|
||||||
@@ -253,18 +254,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
const result = await provider.search(cmd.args, 1);
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await provider.search(cmd.args, 1);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ECONNREFUSED") {
|
|
||||||
this.logger.error({ err, platform: provider.platform }, "Music API server is not reachable");
|
|
||||||
return `Music API server for ${provider.platform} is not running. Please check the service.`;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.songs.length === 0)
|
if (result.songs.length === 0)
|
||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
@@ -273,6 +263,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}`;
|
||||||
@@ -281,18 +273,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !add <song name>";
|
if (!cmd.args) return "Usage: !add <song name>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
const result = await provider.search(cmd.args, 1);
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await provider.search(cmd.args, 1);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "ECONNREFUSED") {
|
|
||||||
this.logger.error({ err, platform: provider.platform }, "Music API server is not reachable");
|
|
||||||
return `Music API server for ${provider.platform} is not running. Please check the service.`;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.songs.length === 0)
|
if (result.songs.length === 0)
|
||||||
return `No results found for: ${cmd.args}`;
|
return `No results found for: ${cmd.args}`;
|
||||||
|
|
||||||
@@ -572,4 +553,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
@@ -131,12 +131,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> {
|
||||||
@@ -150,6 +163,7 @@ export class BotManager {
|
|||||||
port: saved.serverPort,
|
port: saved.serverPort,
|
||||||
queryPort: 10011,
|
queryPort: 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,
|
||||||
},
|
},
|
||||||
@@ -163,18 +177,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(
|
||||||
@@ -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 {
|
shutdown(): void {
|
||||||
for (const bot of this.bots.values()) {
|
for (const bot of this.bots.values()) {
|
||||||
bot.disconnect();
|
bot.disconnect();
|
||||||
|
|||||||
+24
-6
@@ -24,6 +24,7 @@ export interface BotInstance {
|
|||||||
defaultChannel: string;
|
defaultChannel: string;
|
||||||
channelPassword: string;
|
channelPassword: string;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
identity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotDatabase {
|
export interface BotDatabase {
|
||||||
@@ -36,6 +37,15 @@ export interface BotDatabase {
|
|||||||
close(): void;
|
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 {
|
function initTables(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS play_history (
|
CREATE TABLE IF NOT EXISTS play_history (
|
||||||
@@ -58,7 +68,8 @@ function initTables(db: Database.Database): void {
|
|||||||
nickname TEXT NOT NULL,
|
nickname TEXT NOT NULL,
|
||||||
defaultChannel TEXT NOT NULL,
|
defaultChannel TEXT NOT NULL,
|
||||||
channelPassword 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);
|
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)
|
||||||
@@ -78,8 +90,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)
|
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
|
||||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart)
|
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @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,
|
||||||
@@ -87,7 +99,8 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
nickname = excluded.nickname,
|
nickname = excluded.nickname,
|
||||||
defaultChannel = excluded.defaultChannel,
|
defaultChannel = excluded.defaultChannel,
|
||||||
channelPassword = excluded.channelPassword,
|
channelPassword = excluded.channelPassword,
|
||||||
autoStart = excluded.autoStart
|
autoStart = excluded.autoStart,
|
||||||
|
identity = excluded.identity
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
|
||||||
@@ -109,14 +122,19 @@ 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,
|
||||||
|
identity: r.identity ?? undefined,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBotInstance(id) {
|
deleteBotInstance(id) {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ async function main() {
|
|||||||
saveConfig(CONFIG_PATH, config);
|
saveConfig(CONFIG_PATH, config);
|
||||||
|
|
||||||
const logger = createLogger(LOG_DIR);
|
const logger = createLogger(LOG_DIR);
|
||||||
|
|
||||||
|
// Prevent unhandled errors from crashing the process
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
logger.error({ err }, "Uncaught exception");
|
||||||
|
});
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logger.error({ reason }, "Unhandled promise rejection");
|
||||||
|
});
|
||||||
const db = createDatabase(DB_PATH);
|
const db = createDatabase(DB_PATH);
|
||||||
|
|
||||||
const apiServer = createApiServerManager(
|
const apiServer = createApiServerManager(
|
||||||
|
|||||||
+46
-22
@@ -153,29 +153,41 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
async getQrCode(): Promise<QrCodeResult> {
|
async getQrCode(): Promise<QrCodeResult> {
|
||||||
const res = await this.api.get("/getQQLoginQr");
|
const res = await this.api.get("/getQQLoginQr");
|
||||||
|
const qrsig = res.data?.qrsig ?? "";
|
||||||
|
const ptqrtoken = res.data?.ptqrtoken ?? "";
|
||||||
|
// @sansenjian/qq-music-api's checkQQLoginQr requires BOTH qrsig and
|
||||||
|
// ptqrtoken, so encode both into the opaque key the caller polls with.
|
||||||
return {
|
return {
|
||||||
qrUrl: "",
|
qrUrl: "",
|
||||||
qrImg: res.data?.img ?? "",
|
qrImg: res.data?.img ?? "",
|
||||||
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
|
key: qrsig && ptqrtoken ? `${qrsig}|${ptqrtoken}` : "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkQrCodeStatus(
|
async checkQrCodeStatus(
|
||||||
key: string
|
key: string
|
||||||
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
|
||||||
const res = await this.api.get("/checkQQLoginQr", {
|
const [qrsig, ptqrtoken] = key.split("|");
|
||||||
params: { qrsig: key },
|
if (!qrsig || !ptqrtoken) return "expired";
|
||||||
});
|
try {
|
||||||
const code = res.data?.code ?? res.data?.response?.code;
|
// /checkQQLoginQr is POST, and the success body is
|
||||||
if (code === 0) {
|
// { isOk, refresh, message, session: { cookie, ... } }
|
||||||
if (res.data?.cookie) {
|
const res = await this.api.post("/checkQQLoginQr", null, {
|
||||||
this.cookie = res.data.cookie;
|
params: { qrsig, ptqrtoken },
|
||||||
|
});
|
||||||
|
if (res.data?.isOk) {
|
||||||
|
const cookie = res.data?.session?.cookie;
|
||||||
|
if (cookie) {
|
||||||
|
this.cookie = cookie;
|
||||||
|
}
|
||||||
|
return "confirmed";
|
||||||
}
|
}
|
||||||
return "confirmed";
|
if (res.data?.refresh) return "expired";
|
||||||
|
// The upstream lib does not distinguish "scanned" from "waiting"
|
||||||
|
return "waiting";
|
||||||
|
} catch {
|
||||||
|
return "expired";
|
||||||
}
|
}
|
||||||
if (code === 1) return "scanned";
|
|
||||||
if (code === 2) return "waiting";
|
|
||||||
return "expired";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCookie(cookie: string): void {
|
setCookie(cookie: string): void {
|
||||||
@@ -188,20 +200,32 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
async getAuthStatus(): Promise<AuthStatus> {
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
if (!this.cookie) return { loggedIn: false };
|
if (!this.cookie) return { loggedIn: false };
|
||||||
|
|
||||||
|
// @sansenjian/qq-music-api does not expose a dedicated "am I logged in"
|
||||||
|
// endpoint. /user/getUserAvatar only builds a static avatar URL from a
|
||||||
|
// uin and never talks to QQ, so it cannot be used to validate a cookie.
|
||||||
|
// Instead, parse the uin from the cookie and round-trip it through
|
||||||
|
// /user/getUserPlaylists, which actually hits QQ Music's servers using
|
||||||
|
// the provided cookie. A successful response (code=0) means the cookie
|
||||||
|
// is still valid.
|
||||||
|
const uinMatch = this.cookie.match(/(?:^|;\s*)uin=([^;]+)/);
|
||||||
|
const uin = uinMatch?.[1];
|
||||||
|
if (!uin) return { loggedIn: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.api.get("/getUserAvatar", {
|
const res = await this.api.get("/user/getUserPlaylists", {
|
||||||
params: { ...this.cookieParams },
|
params: { uin, limit: 1, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
if (res.data?.response?.data) {
|
if (res.data?.response?.code !== 0) {
|
||||||
return {
|
return { loggedIn: false };
|
||||||
loggedIn: true,
|
|
||||||
nickname: res.data.response.data.nickname,
|
|
||||||
avatarUrl: res.data.response.data.headpic,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
loggedIn: true,
|
||||||
|
nickname: `QQ ${uin}`,
|
||||||
|
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=140`,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
return { loggedIn: false };
|
||||||
}
|
}
|
||||||
return { loggedIn: false };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,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}`;
|
||||||
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
|
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
|
||||||
|
|
||||||
@@ -114,6 +126,7 @@ export class TS3Client extends EventEmitter {
|
|||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
await this.client.waitConnected();
|
await this.client.waitConnected();
|
||||||
this.clientId = this.client.clientID();
|
this.clientId = this.client.clientID();
|
||||||
|
this.voiceFramesSent = 0;
|
||||||
this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
|
this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
|
||||||
|
|
||||||
// Join default channel if specified
|
// Join default channel if specified
|
||||||
|
|||||||
@@ -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}` });
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
logger.error({ err }, "HTTP server error");
|
||||||
|
});
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
wss.on("error", (err) => {
|
||||||
|
logger.error({ err }, "WebSocket server error");
|
||||||
|
});
|
||||||
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export function setupWebSocket(
|
|||||||
const message = JSON.stringify(data);
|
const message = JSON.stringify(data);
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(message);
|
try {
|
||||||
|
client.send(message);
|
||||||
|
} catch {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user