mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ecd92d7c | ||
|
|
f41cd888f9 | ||
|
|
4de8ac1810 | ||
|
|
f9e2a210b7 | ||
|
|
62fb67933d | ||
|
|
43f4dd94aa | ||
|
|
1a5bd74357 | ||
|
|
31e5e08def | ||
|
|
5d74979af3 | ||
|
|
24da8cfc2e | ||
|
|
e3b4e1634d | ||
|
|
2f186ec002 | ||
|
|
08170a2574 | ||
|
|
7eb8477dad | ||
|
|
2fb0cd2489 | ||
|
|
8dcdf01129 | ||
|
|
da26435385 | ||
|
|
364112d94f | ||
|
|
82a23d291e | ||
|
|
47514f57aa | ||
|
|
1bd6ab9975 | ||
|
|
314d6ec955 | ||
|
|
66ae948371 | ||
|
|
fecda7cce3 | ||
|
|
b9e42d543c | ||
|
|
4406eeacbe | ||
|
|
0408f6ce3c | ||
|
|
47b69a1f95 | ||
|
|
0e992f2f5f | ||
|
|
7785cc972b | ||
|
|
4b412e6277 | ||
|
|
f0a0ae5f2c | ||
|
|
a56dc5bc85 | ||
|
|
1a663156c3 | ||
|
|
e92628a6b4 | ||
|
|
b4ad78ce84 | ||
|
|
30aced6d36 | ||
|
|
da5b34f5f4 | ||
|
|
010aa8aa8c | ||
|
|
05edca4a9a | ||
|
|
e2b1a5e055 | ||
|
|
4eac2e4dde |
No files matched your search
@@ -6,5 +6,6 @@ dist/
|
|||||||
config.json
|
config.json
|
||||||
cookies/
|
cookies/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
/bin/
|
/bin/
|
||||||
scripts/navbar_bigger.png
|
scripts/navbar_bigger.png
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||||
|
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||||
- **播放历史** — 自动记录所有播放过的歌曲
|
- **播放历史** — 自动记录所有播放过的歌曲
|
||||||
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
|
|
||||||
> *截图即将添加*
|
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
|
||||||
|
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -252,9 +254,11 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!vol <0-100>` | 设置音量 |
|
| `!vol <0-100>` | 设置音量 |
|
||||||
| `!queue` | 查看播放队列 |
|
| `!queue` | 查看播放队列 |
|
||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <ID>` | 加载歌单 |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||||
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
| `!album <ID>` | 加载专辑 |
|
| `!album <ID>` | 加载专辑 |
|
||||||
| `!fm` | 私人 FM(网易云) |
|
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
|
||||||
|
| `!fm` | 私人 FM(网易云,自动续播) |
|
||||||
| `!lyrics` | 显示当前歌词 |
|
| `!lyrics` | 显示当前歌词 |
|
||||||
| `!now` | 当前播放信息 |
|
| `!now` | 当前播放信息 |
|
||||||
| `!vote` | 投票跳过当前歌曲 |
|
| `!vote` | 投票跳过当前歌曲 |
|
||||||
@@ -288,7 +292,8 @@ teamspeak-music-bot/
|
|||||||
│ ├── bot/ # 机器人核心
|
│ ├── bot/ # 机器人核心
|
||||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||||
│ │ └── manager.ts # 多实例生命周期管理
|
│ │ ├── manager.ts # 多实例生命周期管理
|
||||||
|
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
||||||
│ ├── data/ # 数据层
|
│ ├── data/ # 数据层
|
||||||
│ │ ├── config.ts # JSON 配置文件
|
│ │ ├── config.ts # JSON 配置文件
|
||||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
||||||
@@ -473,6 +478,22 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
|||||||
|
|
||||||
### 最新版本
|
### 最新版本
|
||||||
|
|
||||||
|
**机器人形象自动更新(Bot Profile)**
|
||||||
|
|
||||||
|
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
||||||
|
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
|
||||||
|
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
|
||||||
|
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
|
||||||
|
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
||||||
|
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
|
||||||
|
|
||||||
|
**新命令 & FM 修复**
|
||||||
|
|
||||||
|
- **新增 `!artist <歌手名>` 命令**:搜索指定歌手的歌曲并循环播放,支持 `-q`(QQ 音乐)/ `-b`(B站)/ `-y`(YouTube)平台切换。一次加载最多 50 首,自动按歌手名过滤并设为 Loop 模式。
|
||||||
|
- **歌单模糊搜索**:`!playlist` 现在支持歌单名称模糊搜索(如 `!playlist 华语经典`),自动匹配公开歌单 + 个人歌单(网易云 + QQ)。纯数字 ID 和 URL 解析保持兼容。
|
||||||
|
- **修复 `!fm` 播放中断**:私人 FM 几首歌后静音的 bug 已修复。新增自动续播机制(队列低位自动拉取新歌),播放器健康帧追踪防止临时 URL 失败导致永久静音。
|
||||||
|
- **QQ 音乐个人歌单**:QQ Music provider 新增 `getUserPlaylists` 支持,登录后可通过 `!playlist -q <名称>` 模糊搜索个人歌单。
|
||||||
|
|
||||||
**协议层 & 稳定性**
|
**协议层 & 稳定性**
|
||||||
|
|
||||||
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
|
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# FM Bug Fix + Artist Loop + Playlist Fuzzy Search — 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:** Fix FM audio dropout bug, add `!artist` command for artist-based loop playback, and support playlist name fuzzy search in `!playlist`.
|
||||||
|
|
||||||
|
**Architecture:** All changes stay within existing files. The FM fix adds auto-refill logic and a success-tracking mechanism in the player. Playlist search reuses the existing `provider.search()` API that already returns playlists. The `!artist` command is a new command method following the same pattern as `cmdPlay`/`cmdFm`.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Node.js, ffmpeg-static, @honeybbq/teamspeak-client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Change | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `src/bot/instance.ts` | Modify | Add `isFmMode`, `refillFm()`, fix `cmdFm()`, modify `cmdPlaylist()`, add `cmdArtist()`, modify `playNext()` to trigger FM refill |
|
||||||
|
| `src/bot/commands.ts` | Modify | Register `artist` in PUBLIC_COMMANDS, update help text |
|
||||||
|
| `src/audio/player.ts` | Modify | Track healthy frame count, reset `consecutiveFailures` after sustained successful playback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Fix FM — Track healthy playback in AudioPlayer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/audio/player.ts:62-82` (add field)
|
||||||
|
- Modify: `src/audio/player.ts:243-261` (sendNextFrame — track healthy frames)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add healthy frame counter field**
|
||||||
|
|
||||||
|
In `src/audio/player.ts`, after the `consecutiveFailures` field (line ~80), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private healthyFrames = 0;
|
||||||
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Track healthy frames and reset failures in sendNextFrame**
|
||||||
|
|
||||||
|
In `src/audio/player.ts`, in the `sendNextFrame()` method, after line 257 (`this.framesPlayed++;`), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.healthyFrames++;
|
||||||
|
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reset healthyFrames in play() and stop()**
|
||||||
|
|
||||||
|
In `play()`, after `this.framesPlayed = 0;` (line ~95), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.healthyFrames = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `stop()`, after `this.framesPlayed = 0;` (line ~181), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.healthyFrames = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/audio/player.ts
|
||||||
|
git commit -m "fix(player): reset consecutiveFailures after sustained healthy playback"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Fix FM — Add auto-refill logic in BotInstance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot/instance.ts:62-66` (add fields)
|
||||||
|
- Modify: `src/bot/instance.ts:557-573` (cmdFm)
|
||||||
|
- Modify: `src/bot/instance.ts:642-673` (playNext — add refill trigger)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add isFmMode field**
|
||||||
|
|
||||||
|
In `src/bot/instance.ts`, after `private profileManager: BotProfileManager;` (line ~66), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add refillFm method**
|
||||||
|
|
||||||
|
In `src/bot/instance.ts`, before the `cmdVote` method (after `cmdFm`'s closing brace), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async refillFm(): Promise<void> {
|
||||||
|
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
||||||
|
try {
|
||||||
|
const songs = await this.neteaseProvider.getPersonalFm();
|
||||||
|
if (songs.length === 0) return;
|
||||||
|
for (const song of songs) {
|
||||||
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
|
}
|
||||||
|
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err }, "Failed to refill FM queue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify cmdFm to set isFmMode and use RandomLoop**
|
||||||
|
|
||||||
|
Replace the existing `cmdFm` method (lines 557-573) with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async cmdFm(): Promise<string> {
|
||||||
|
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) {
|
||||||
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
|
}
|
||||||
|
this.queue.setMode(PlayMode.RandomLoop);
|
||||||
|
this.isFmMode = true;
|
||||||
|
this.player.resetFailures();
|
||||||
|
|
||||||
|
const first = this.queue.play();
|
||||||
|
if (first) await this.resolveAndPlay(first);
|
||||||
|
this.emit("stateChange");
|
||||||
|
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Modify playNext to trigger FM refill and check isFmMode**
|
||||||
|
|
||||||
|
In `playNext()`, replace the `else` branch (lines 665-668) that handles `queue.next() === null`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} else {
|
||||||
|
// FM mode: try to refill instead of stopping
|
||||||
|
if (this.isFmMode) {
|
||||||
|
await this.refillFm();
|
||||||
|
const refillNext = this.queue.next();
|
||||||
|
if (refillNext) {
|
||||||
|
const started = await this.resolveAndPlay(refillNext);
|
||||||
|
if (!started) {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add a proactive refill after successful advance. At the end of the `if (next)` block, after `this.emit("stateChange");` is handled outside the if/else, add this right after `resolveAndPlay` succeeds (inside the `if (next)` block, after the retry loop):
|
||||||
|
|
||||||
|
After the `if (!started)` block and before the closing `}` of `if (next)`, insert:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Proactive FM refill when running low
|
||||||
|
if (this.isFmMode && this.queue.size() - (this.queue.getCurrentIndex()) <= 3) {
|
||||||
|
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — `this.emit("stateChange")` is outside the `if (next)` block. Let me re-read the original code structure...
|
||||||
|
|
||||||
|
The original `playNext()` structure is:
|
||||||
|
```
|
||||||
|
if (next) {
|
||||||
|
let started = await resolveAndPlay(next)
|
||||||
|
if (!started) { retry loop... }
|
||||||
|
if (!started) { stop }
|
||||||
|
} else {
|
||||||
|
stop
|
||||||
|
}
|
||||||
|
emit("stateChange")
|
||||||
|
```
|
||||||
|
|
||||||
|
So I need to add the proactive refill inside the `if (next)` block, right after `resolveAndPlay` succeeds. Let me write this more carefully:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async playNext(): Promise<void> {
|
||||||
|
if (this.isAdvancing || !this.connected) return;
|
||||||
|
this.isAdvancing = true;
|
||||||
|
try {
|
||||||
|
this.voteSkipUsers.clear();
|
||||||
|
const next = this.queue.next();
|
||||||
|
if (next) {
|
||||||
|
let started = await this.resolveAndPlay(next);
|
||||||
|
if (!started) {
|
||||||
|
for (let i = 0; i < 3 && this.connected; i++) {
|
||||||
|
const retry = this.queue.next();
|
||||||
|
if (!retry) break;
|
||||||
|
if (await this.resolveAndPlay(retry)) {
|
||||||
|
started = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
|
||||||
|
// Proactive refill: when queue is running low, fetch more FM songs
|
||||||
|
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Queue exhausted — in FM mode, refill instead of stopping
|
||||||
|
if (this.isFmMode) {
|
||||||
|
await this.refillFm();
|
||||||
|
const refillNext = this.queue.next();
|
||||||
|
if (refillNext) {
|
||||||
|
await this.resolveAndPlay(refillNext);
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit("stateChange");
|
||||||
|
} finally {
|
||||||
|
this.isAdvancing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
OK this is getting complex. Let me simplify the plan — I'll structure it more clearly.
|
||||||
|
|
||||||
|
Also I need to clear `isFmMode` when user issues stop/clear or manually plays something else.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Clear isFmMode in stop/clear/play commands**
|
||||||
|
|
||||||
|
In `cmdStop()`, after `this.queue.clear();`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `cmdClear()` (same line), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `cmdPlay()`, after `this.queue.clear();`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `cmdPlaylist()`, after `this.queue.clear();`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `cmdAlbum()`, after `this.queue.clear();`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.isFmMode = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot/instance.ts
|
||||||
|
git commit -m "fix: FM auto-refill to prevent audio dropout after initial batch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Playlist Fuzzy Search
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot/instance.ts:524-539` (cmdPlaylist)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modify cmdPlaylist to support name search**
|
||||||
|
|
||||||
|
Replace the `cmdPlaylist` method (lines 524-539) with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
||||||
|
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||||
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
|
||||||
|
// Determine if input is a numeric ID or a name search
|
||||||
|
const id = this.extractId(cmd.args);
|
||||||
|
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||||
|
|
||||||
|
let playlistId: string;
|
||||||
|
|
||||||
|
if (isNumericId || id !== cmd.args) {
|
||||||
|
// Input is a numeric ID or URL containing an ID — use existing logic
|
||||||
|
playlistId = id;
|
||||||
|
} else {
|
||||||
|
// Name-based search
|
||||||
|
const result = await provider.search(cmd.args);
|
||||||
|
let playlists = result.playlists ?? [];
|
||||||
|
|
||||||
|
// Also search user's personal playlists if logged in
|
||||||
|
if (provider.getUserPlaylists) {
|
||||||
|
try {
|
||||||
|
const userPlaylists = await provider.getUserPlaylists();
|
||||||
|
const query = cmd.args.toLowerCase();
|
||||||
|
const matched = userPlaylists.filter(
|
||||||
|
p => p.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
// Merge: public results first (API-ranked), then user matches
|
||||||
|
playlists = [...playlists, ...matched];
|
||||||
|
} catch {
|
||||||
|
// User playlists unavailable — continue with public results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.length === 0)
|
||||||
|
return `No playlists found for: ${cmd.args}`;
|
||||||
|
playlistId = playlists[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await provider.getPlaylistSongs(playlistId);
|
||||||
|
if (songs.length === 0) return "Playlist is empty or not found";
|
||||||
|
|
||||||
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
|
for (const song of songs) {
|
||||||
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
|
}
|
||||||
|
const first = this.queue.play();
|
||||||
|
if (first) await this.resolveAndPlay(first);
|
||||||
|
this.emit("stateChange");
|
||||||
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update help text to reflect new usage**
|
||||||
|
|
||||||
|
In `cmdHelp()` (line ~633), change the playlist line from:
|
||||||
|
|
||||||
|
```
|
||||||
|
`${p}playlist <id> — Load playlist`
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```
|
||||||
|
`${p}playlist <name or id> — Load playlist by name or ID`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot/instance.ts
|
||||||
|
git commit -m "feat: support playlist name fuzzy search in !playlist command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Artist Loop Command
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/bot/commands.ts:8-11` (PUBLIC_COMMANDS)
|
||||||
|
- Modify: `src/bot/commands.ts:248-260` (AUDIO_COMMANDS in instance.ts — actually in instance.ts)
|
||||||
|
- Modify: `src/bot/instance.ts:244-314` (executeCommand switch + add cmdArtist)
|
||||||
|
|
||||||
|
Wait, AUDIO_COMMANDS is in instance.ts executeCommand. Let me check...
|
||||||
|
|
||||||
|
Actually looking back at instance.ts, the AUDIO_COMMANDS set is local to executeCommand. I don't need to add artist there since it will be handled in the switch.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register `artist` in PUBLIC_COMMANDS**
|
||||||
|
|
||||||
|
In `src/bot/commands.ts`, line 9, add `"artist"` to the PUBLIC_COMMANDS set:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const PUBLIC_COMMANDS = new Set([
|
||||||
|
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
||||||
|
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
||||||
|
"artist",
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `artist` to the AUDIO_COMMANDS set in executeCommand**
|
||||||
|
|
||||||
|
In `src/bot/instance.ts`, in the `executeCommand` method, add `"artist"` to the AUDIO_COMMANDS set (line ~253):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const AUDIO_COMMANDS = new Set([
|
||||||
|
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
|
||||||
|
"artist",
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `artist` case to the switch in executeCommand**
|
||||||
|
|
||||||
|
In `src/bot/instance.ts`, after the `case "fm":` block (line ~300), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case "artist":
|
||||||
|
return this.cmdArtist(cmd);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement cmdArtist method**
|
||||||
|
|
||||||
|
Add the `cmdArtist` method in `src/bot/instance.ts`, after `cmdFm()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||||
|
if (!cmd.args) return "Usage: !artist <artist name>";
|
||||||
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
const result = await provider.search(cmd.args, 50);
|
||||||
|
if (result.songs.length === 0)
|
||||||
|
return `No results found for artist: ${cmd.args}`;
|
||||||
|
|
||||||
|
const query = cmd.args.toLowerCase();
|
||||||
|
let filtered = result.songs.filter(
|
||||||
|
s => s.artist.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback to unfiltered results if filtering drops everything
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
filtered = result.songs.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
|
for (const song of filtered) {
|
||||||
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
|
}
|
||||||
|
this.queue.setMode(PlayMode.Loop);
|
||||||
|
this.player.resetFailures();
|
||||||
|
|
||||||
|
const first = this.queue.play();
|
||||||
|
if (first) await this.resolveAndPlay(first);
|
||||||
|
this.emit("stateChange");
|
||||||
|
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update help text**
|
||||||
|
|
||||||
|
In `cmdHelp()`, add the artist help line after the fm line:
|
||||||
|
|
||||||
|
```
|
||||||
|
`${p}artist <name> — Play songs by artist (loop)`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/bot/commands.ts src/bot/instance.ts
|
||||||
|
git commit -m "feat: add !artist command for artist-based loop playback"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Type-check and verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All modified files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run type check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify command parsing**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/proxxy/project/teamspeak-music-bot && node --loader ts-node/esm -e "
|
||||||
|
const { parseCommand } = await import('./src/bot/commands.ts');
|
||||||
|
console.log(parseCommand('!artist 周杰伦', '!'));
|
||||||
|
console.log(parseCommand('!artist 周杰伦 -q', '!'));
|
||||||
|
console.log(parseCommand('!playlist 华语经典', '!'));
|
||||||
|
console.log(parseCommand('!playlist 123456', '!'));
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All parse correctly; `artist` with args "周杰伦", `playlist` with args "华语经典" and "123456".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit any fixes from type check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "chore: type fixes from final verification"
|
||||||
|
```
|
||||||
|
(Only if there were issues)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Self-Review Checklist
|
||||||
|
|
||||||
|
1. **Spec coverage:**
|
||||||
|
- FM bug fix → Tasks 1, 2 (healthy frame tracking + auto-refill)
|
||||||
|
- Playlist fuzzy search → Task 3
|
||||||
|
- Artist loop → Task 4
|
||||||
|
- Verification → Task 5
|
||||||
|
|
||||||
|
2. **No placeholders** — all steps have exact code.
|
||||||
|
|
||||||
|
3. **Type consistency:**
|
||||||
|
- `isFmMode: boolean` — used in cmdFm, cmdStop, cmdClear, cmdPlay, cmdPlaylist, cmdAlbum, playNext, refillFm ✓
|
||||||
|
- `refillFm(): Promise<void>` — called from cmdFm (indirectly via playNext trigger), playNext ✓
|
||||||
|
- `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Bot Profile Manager — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|
||||||
|
|---|---------|---------------|-----------------|---------------|---------------|
|
||||||
|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
|
||||||
|
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
|
||||||
|
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
|
||||||
|
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
|
||||||
|
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
|
||||||
|
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
resolveAndPlay() success / stop / clear / playNext exhausted
|
||||||
|
↓
|
||||||
|
BotInstance → BotProfileManager.onSongChange(song | null)
|
||||||
|
├─ updateAvatar(coverUrl | null) [fire-and-forget]
|
||||||
|
├─ updateDescription(song | null)
|
||||||
|
├─ updateNickname(song | null)
|
||||||
|
├─ updateAwayStatus(song | null)
|
||||||
|
├─ updateChannelDescription(song | null)
|
||||||
|
└─ sendNowPlayingMessage(song) [only when song != null]
|
||||||
|
|
||||||
|
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
|
||||||
|
BotInstance.disconnect() → (no action needed, server cleans up)
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
|
||||||
|
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
|
||||||
|
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
|
||||||
|
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
|
||||||
|
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
|
||||||
|
|
||||||
|
## TS3Client Layer Extensions
|
||||||
|
|
||||||
|
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
execCommand(cmd: string): Promise<void>
|
||||||
|
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
|
||||||
|
fileTransferInitUpload(channelID: bigint, path: string, password: string,
|
||||||
|
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
|
||||||
|
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
|
||||||
|
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
|
||||||
|
|
||||||
|
## BotProfileManager Detail
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ProfileConfig {
|
||||||
|
avatarEnabled: boolean; // default true
|
||||||
|
descriptionEnabled: boolean; // default true
|
||||||
|
nicknameEnabled: boolean; // default true
|
||||||
|
awayStatusEnabled: boolean; // default true
|
||||||
|
channelDescEnabled: boolean; // default true
|
||||||
|
nowPlayingMsgEnabled: boolean; // default true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Handling
|
||||||
|
|
||||||
|
- Each feature has an independent `permDenied: boolean` flag.
|
||||||
|
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
|
||||||
|
- On `onConnect()` → reset all flags (new connection may have different permissions).
|
||||||
|
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
|
||||||
|
|
||||||
|
### Nickname Truncation
|
||||||
|
|
||||||
|
- Format: `♪ {songInfo} | {defaultNickname}`
|
||||||
|
- TS3 max nickname: 30 characters
|
||||||
|
- If total > 30: truncate songInfo, keep defaultNickname
|
||||||
|
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
|
||||||
|
|
||||||
|
### Avatar Upload Flow (TS3)
|
||||||
|
|
||||||
|
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
|
||||||
|
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
|
||||||
|
3. `uploadFileData(host, info, Readable.from(buffer))`
|
||||||
|
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
|
||||||
|
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
|
||||||
|
|
||||||
|
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
|
||||||
|
|
||||||
|
### Fire-and-Forget
|
||||||
|
|
||||||
|
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
|
||||||
|
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- All profile operations are async, never block playback
|
||||||
|
- Uses existing `axios` dependency for image download
|
||||||
|
- MD5 via Node.js built-in `crypto`
|
||||||
|
- No new npm dependencies required
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
|
||||||
|
|
||||||
|
Date: 2026-04-27
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three features for the TeamSpeak Music Bot:
|
||||||
|
1. New `!artist <name>` command — loop playback filtered by artist
|
||||||
|
2. Fuzzy playlist name search in existing `!playlist` command
|
||||||
|
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1: `!artist` Command
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
|
||||||
|
2. Call `provider.search(歌手名, 50)` to get up to 50 results
|
||||||
|
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
|
||||||
|
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
|
||||||
|
5. Clear current queue, add filtered songs, set mode to `Loop`
|
||||||
|
6. Play first song via `resolveAndPlay`
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
|
||||||
|
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
|
||||||
|
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
|
||||||
|
- `src/bot/instance.ts`: New `cmdArtist()` method
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2: Playlist Fuzzy Search
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
|
||||||
|
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
|
||||||
|
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
|
||||||
|
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
|
||||||
|
5. Merge results: public search results first (sorted by API relevance), then user matches
|
||||||
|
6. Take the first playlist, load its songs, play
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
|
||||||
|
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
|
||||||
|
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
|
||||||
|
- `src/bot/commands.ts`: Update help text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 3: FM Bug Fix
|
||||||
|
|
||||||
|
### Root Cause Analysis
|
||||||
|
|
||||||
|
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
|
||||||
|
|
||||||
|
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
|
||||||
|
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
|
||||||
|
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
|
||||||
|
|
||||||
|
The most likely scenario for "no audio but status shows playing + song names changing":
|
||||||
|
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
|
||||||
|
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
|
||||||
|
3. ffmpeg exits quickly (clean exit or error)
|
||||||
|
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
|
||||||
|
5. `playNext()` advances to the next song
|
||||||
|
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
|
||||||
|
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
|
||||||
|
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
|
||||||
|
|
||||||
|
### Fix Strategy
|
||||||
|
|
||||||
|
**Fix 1 — FM auto-refill (primary fix):**
|
||||||
|
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
|
||||||
|
- Add a `refillFm()` method that fetches more FM songs and appends to queue
|
||||||
|
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
|
||||||
|
- Track FM state with a boolean flag `isFmMode` on the instance
|
||||||
|
|
||||||
|
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
|
||||||
|
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
|
||||||
|
- This prevents transient URL failures from accumulating toward the hard limit
|
||||||
|
|
||||||
|
**Fix 3 — FM refill before queue exhaustion:**
|
||||||
|
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
|
||||||
|
- If so, fire an async refill (don't block playback)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
|
||||||
|
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **FM bug fix** first — it's a bug fix affecting current users
|
||||||
|
2. **Playlist fuzzy search** — small change, quick win
|
||||||
|
3. **Artist loop** — new feature, depends on queue/player being stable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
|
||||||
|
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
|
||||||
|
- Artist: Test with known artist names, test edge case (no results), test with platform flags
|
||||||
+294
-64
@@ -1,91 +1,321 @@
|
|||||||
@echo off
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
chcp 65001 >nul
|
||||||
title TSMusicBot Setup
|
title TSMusicBot Setup
|
||||||
echo ============================================
|
|
||||||
echo TSMusicBot - First-Time Setup (Windows)
|
:: ============================================================
|
||||||
echo ============================================
|
:: TSMusicBot Setup Script (Robust Edition)
|
||||||
echo.
|
:: - Auto-detect China network, switch to npmmirror
|
||||||
|
:: - Strict error checking at every step
|
||||||
|
:: - Detailed logging to setup.log
|
||||||
|
:: - Skip already-completed steps on re-run
|
||||||
|
:: ============================================================
|
||||||
|
|
||||||
|
set "SCRIPT_VERSION=2.0"
|
||||||
|
set "MIN_NODE_MAJOR=20"
|
||||||
|
set "LOG_FILE=%~dp0..\setup.log"
|
||||||
|
set "FAILED=0"
|
||||||
|
|
||||||
:: Resolve project root (one level up from scripts/)
|
:: Resolve project root (one level up from scripts/)
|
||||||
cd /d "%~dp0.."
|
cd /d "%~dp0.." || (
|
||||||
|
echo [FATAL] Cannot change to project directory.
|
||||||
:: ---- Step 1: Check / install Node.js ----
|
|
||||||
where node >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo Node.js not found. Attempting automatic installation...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Try winget first (available on Windows 10 1709+ and Windows 11)
|
|
||||||
where winget >nul 2>&1
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo Installing Node.js via winget...
|
|
||||||
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo winget installation failed. Please install Node.js manually from https://nodejs.org
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
:: Refresh PATH so node is available in this session
|
|
||||||
call refreshenv >nul 2>&1
|
|
||||||
:: If refreshenv is not available, ask user to restart
|
|
||||||
where node >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo.
|
|
||||||
echo Node.js was installed but is not yet available in this terminal.
|
|
||||||
echo Please close this window and run setup.bat again.
|
|
||||||
pause
|
|
||||||
exit /b 0
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo winget is not available on this system.
|
|
||||||
echo Please install Node.js 20 LTS manually from https://nodejs.org
|
|
||||||
echo After installing, close this window and run setup.bat again.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo [OK] Node.js found.
|
|
||||||
node --version
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: ---- Step 2: Install npm dependencies ----
|
|
||||||
echo Installing dependencies (this may take a few minutes)...
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo.
|
|
||||||
echo npm install failed. Check the error messages above.
|
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
echo [OK] Dependencies installed.
|
|
||||||
|
set "PROJECT_ROOT=%cd%"
|
||||||
|
|
||||||
|
:: ---- Initialize log ----
|
||||||
|
echo. > "%LOG_FILE%"
|
||||||
|
call :log "============================================"
|
||||||
|
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
|
||||||
|
call :log " Started: %date% %time%"
|
||||||
|
call :log " Project root: %PROJECT_ROOT%"
|
||||||
|
call :log "============================================"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo TSMusicBot - First-Time Setup (Windows)
|
||||||
|
echo Version %SCRIPT_VERSION%
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Log file: %LOG_FILE%
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ---- Step 3: Build the project ----
|
:: ============================================================
|
||||||
echo Building TypeScript project...
|
:: Step 1: Check Node.js
|
||||||
call npx tsc
|
:: ============================================================
|
||||||
if %errorlevel% neq 0 (
|
call :step "1/6" "Checking Node.js"
|
||||||
|
|
||||||
|
where node >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :error "Node.js not found in PATH."
|
||||||
|
echo.
|
||||||
|
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
|
||||||
|
echo - https://nodejs.org/ ^(official^)
|
||||||
|
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
|
||||||
|
echo.
|
||||||
|
echo After installation:
|
||||||
|
echo 1. Close this window completely
|
||||||
|
echo 2. Open a NEW Command Prompt
|
||||||
|
echo 3. Run scripts\setup.bat again
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Check Node version >= 20
|
||||||
|
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
|
||||||
|
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
|
||||||
|
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Robust version parse
|
||||||
|
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
|
||||||
|
set "NODE_VER_NUM=%NODE_VER:v=%"
|
||||||
|
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
|
||||||
|
|
||||||
|
call :log "Node.js version: %NODE_VER%"
|
||||||
|
echo [OK] Node.js found: %NODE_VER%
|
||||||
|
|
||||||
|
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
|
||||||
|
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
|
||||||
|
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Step 2: Check npm
|
||||||
|
:: ============================================================
|
||||||
|
call :step "2/6" "Checking npm"
|
||||||
|
|
||||||
|
where npm >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :error "npm not found. This is unusual since Node.js is installed."
|
||||||
|
echo Please reinstall Node.js to fix this.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
|
||||||
|
call :log "npm version: %NPM_VER%"
|
||||||
|
echo [OK] npm found: %NPM_VER%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Step 3: Detect network and configure mirror
|
||||||
|
:: ============================================================
|
||||||
|
call :step "3/6" "Checking network"
|
||||||
|
|
||||||
|
set "USE_MIRROR=0"
|
||||||
|
|
||||||
|
:: Try reaching npm registry with a short timeout
|
||||||
|
echo Testing connection to registry.npmjs.org...
|
||||||
|
call :log "Testing npm registry connectivity..."
|
||||||
|
|
||||||
|
:: Use curl if available (more reliable than ping for HTTPS)
|
||||||
|
where curl >nul 2>&1
|
||||||
|
if not errorlevel 1 (
|
||||||
|
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
|
||||||
|
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
|
||||||
|
del "%TEMP%\npmtest.txt" >nul 2>&1
|
||||||
|
if "!HTTP_CODE!"=="200" (
|
||||||
|
echo [OK] npm registry reachable.
|
||||||
|
call :log "npm registry HTTP 200 OK"
|
||||||
|
) else (
|
||||||
|
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
|
||||||
|
call :log "npm registry returned: !HTTP_CODE!"
|
||||||
|
set "USE_MIRROR=1"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
:: Fallback to ping
|
||||||
|
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [WARN] Cannot reach npm registry quickly.
|
||||||
|
set "USE_MIRROR=1"
|
||||||
|
) else (
|
||||||
|
echo [OK] npm registry reachable.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%USE_MIRROR%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
|
||||||
|
call :log "Switching to npmmirror.com"
|
||||||
|
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
|
||||||
|
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
|
||||||
|
echo [OK] Mirror configured.
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Step 4: Install backend dependencies
|
||||||
|
:: ============================================================
|
||||||
|
call :step "4/6" "Installing backend dependencies"
|
||||||
|
|
||||||
|
if exist "node_modules\.package-lock.json" (
|
||||||
|
echo Found existing node_modules. Checking integrity...
|
||||||
|
call :log "Existing node_modules detected, running npm install to verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
|
||||||
|
echo Press Ctrl+C to abort.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
call npm install >>"%LOG_FILE%" 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :error "Backend npm install failed."
|
||||||
|
echo.
|
||||||
|
echo Common causes:
|
||||||
|
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
|
||||||
|
echo - Native module compile failure ^(missing Python/VS Build Tools^)
|
||||||
|
echo - Disk space full
|
||||||
|
echo.
|
||||||
|
echo Try manually:
|
||||||
|
echo cd /d "%PROJECT_ROOT%"
|
||||||
|
echo npm install --verbose
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [OK] Backend dependencies installed.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Step 5: Install frontend dependencies
|
||||||
|
:: ============================================================
|
||||||
|
call :step "5/6" "Installing frontend dependencies"
|
||||||
|
|
||||||
|
if not exist "web\package.json" (
|
||||||
|
call :error "web\package.json not found. Repository may be incomplete."
|
||||||
|
echo Please re-clone the repository:
|
||||||
|
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Running: npm install ^(in web/ directory^)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
pushd web >nul
|
||||||
|
call npm install >>"%LOG_FILE%" 2>&1
|
||||||
|
set "WEB_INSTALL_RESULT=!errorlevel!"
|
||||||
|
popd >nul
|
||||||
|
|
||||||
|
if !WEB_INSTALL_RESULT! neq 0 (
|
||||||
|
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
|
||||||
|
echo.
|
||||||
|
echo Try manually:
|
||||||
|
echo cd /d "%PROJECT_ROOT%\web"
|
||||||
|
echo npm install --verbose
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [OK] Frontend dependencies installed.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Step 6: Build project (backend + frontend)
|
||||||
|
:: ============================================================
|
||||||
|
call :step "6/6" "Building project"
|
||||||
|
|
||||||
|
echo Running: npm run build
|
||||||
|
echo.
|
||||||
|
|
||||||
|
call npm run build >>"%LOG_FILE%" 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
call :error "Build failed."
|
||||||
|
echo.
|
||||||
|
echo Check the log file for details: %LOG_FILE%
|
||||||
|
echo.
|
||||||
|
echo Try manually:
|
||||||
|
echo cd /d "%PROJECT_ROOT%"
|
||||||
|
echo npm run build
|
||||||
echo.
|
echo.
|
||||||
echo Build failed. Check the error messages above.
|
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
echo [OK] Build succeeded.
|
echo [OK] Build succeeded.
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ---- Step 4: Create default config if missing ----
|
:: ============================================================
|
||||||
|
:: Verify build outputs
|
||||||
|
:: ============================================================
|
||||||
|
echo Verifying build outputs...
|
||||||
|
set "BUILD_OK=1"
|
||||||
|
|
||||||
|
if not exist "dist" (
|
||||||
|
call :error "dist/ directory missing after build."
|
||||||
|
set "BUILD_OK=0"
|
||||||
|
)
|
||||||
|
if not exist "web\dist" (
|
||||||
|
call :error "web\dist/ directory missing after build."
|
||||||
|
set "BUILD_OK=0"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "!BUILD_OK!"=="0" (
|
||||||
|
echo.
|
||||||
|
echo Build completed but expected output is missing.
|
||||||
|
echo Check %LOG_FILE% for details.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [OK] Build outputs verified.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Optional: config.json hint
|
||||||
|
:: ============================================================
|
||||||
if not exist "config.json" (
|
if not exist "config.json" (
|
||||||
echo Creating default config.json...
|
echo [INFO] config.json will be auto-generated on first launch.
|
||||||
echo Please edit config.json with your TeamSpeak server details before starting the bot.
|
|
||||||
) else (
|
) else (
|
||||||
echo [OK] config.json already exists.
|
echo [OK] config.json already exists.
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
:: ---- Done ----
|
:: ============================================================
|
||||||
|
:: Done
|
||||||
|
:: ============================================================
|
||||||
|
call :log "Setup completed successfully at %date% %time%"
|
||||||
|
|
||||||
echo ============================================
|
echo ============================================
|
||||||
echo Setup complete!
|
echo Setup Complete!
|
||||||
echo ============================================
|
echo ============================================
|
||||||
echo.
|
echo.
|
||||||
echo To start the bot, run: scripts\start.bat
|
echo Next steps:
|
||||||
|
echo 1. Run: scripts\start.bat
|
||||||
|
echo 2. Open: http://localhost:3000
|
||||||
|
echo 3. Follow the in-browser setup wizard.
|
||||||
|
echo.
|
||||||
|
echo Setup log saved to: %LOG_FILE%
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:: ============================================================
|
||||||
|
:: Subroutines
|
||||||
|
:: ============================================================
|
||||||
|
:step
|
||||||
|
echo ---- Step %~1: %~2 ----
|
||||||
|
call :log ""
|
||||||
|
call :log "---- Step %~1: %~2 ----"
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:error
|
||||||
|
echo.
|
||||||
|
echo [ERROR] %~1
|
||||||
|
call :log "[ERROR] %~1"
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:log
|
||||||
|
echo [%time%] %~1 >> "%LOG_FILE%"
|
||||||
|
goto :eof
|
||||||
+111
-198
@@ -5,11 +5,12 @@ import { accessSync, chmodSync, constants } from "node:fs";
|
|||||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||||
|
|
||||||
/** Ensure the given binary has execute permission. */
|
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
|
||||||
|
const globalActivePids = new Set<number>();
|
||||||
|
|
||||||
function isExecutable(binPath: string): boolean {
|
function isExecutable(binPath: string): boolean {
|
||||||
try {
|
try {
|
||||||
accessSync(binPath, constants.X_OK);
|
accessSync(binPath, constants.X_OK);
|
||||||
@@ -25,7 +26,6 @@ function isExecutable(binPath: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test if an ffmpeg binary actually works by running -version. */
|
|
||||||
function ffmpegWorks(bin: string): boolean {
|
function ffmpegWorks(bin: string): boolean {
|
||||||
try {
|
try {
|
||||||
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
|
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
|
||||||
@@ -35,39 +35,16 @@ function ffmpegWorks(bin: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolved once at module load.
|
|
||||||
*
|
|
||||||
* Priority: system FFmpeg → bundled ffmpeg-static.
|
|
||||||
*
|
|
||||||
* System-installed FFmpeg is always compatible with the running OS/container,
|
|
||||||
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
|
|
||||||
* (passes `ffmpeg -version` but crashes during actual audio processing due to
|
|
||||||
* incompatible glibc or missing shared libraries).
|
|
||||||
*/
|
|
||||||
const resolvedFfmpeg: string = (() => {
|
const resolvedFfmpeg: string = (() => {
|
||||||
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
|
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
|
||||||
if (ffmpegWorks("ffmpeg")) {
|
|
||||||
return "ffmpeg";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fall back to bundled ffmpeg-static binary
|
|
||||||
// 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 isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
|
||||||
const onWindows = process.platform === "win32";
|
const onWindows = process.platform === "win32";
|
||||||
|
|
||||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
if (ffmpegPath && (onWindows === isWinPath)) {
|
||||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
|
||||||
return ffmpegPath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
|
||||||
return "ffmpeg";
|
return "ffmpeg";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
|
||||||
function getFfmpegCommand(): string {
|
function getFfmpegCommand(): string {
|
||||||
return resolvedFfmpeg;
|
return resolvedFfmpeg;
|
||||||
}
|
}
|
||||||
@@ -93,14 +70,16 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private nextFrameTime = 0;
|
private nextFrameTime = 0;
|
||||||
private currentUrl = "";
|
private currentUrl = "";
|
||||||
private seekOffset = 0;
|
private seekOffset = 0;
|
||||||
private framesPlayed = 0; // ground truth: number of 20ms frames sent
|
private framesPlayed = 0;
|
||||||
private sessionId = 0;
|
private sessionId = 0;
|
||||||
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
|
||||||
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
private static readonly BUFFER_LOW_WATER = 256 * 1024;
|
||||||
private ffmpegPaused = false;
|
private ffmpegPaused = false;
|
||||||
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
|
private spawnFailed = false;
|
||||||
private consecutiveFailures = 0;
|
private consecutiveFailures = 0;
|
||||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
|
private healthyFrames = 0;
|
||||||
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -109,112 +88,127 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
play(url: string, seekSeconds = 0): void {
|
play(url: string, seekSeconds = 0): void {
|
||||||
|
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||||
this.stop();
|
this.stop();
|
||||||
this.sessionId++;
|
|
||||||
const playSessionId = this.sessionId;
|
const currentSessionId = this.sessionId;
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
this.seekOffset = seekSeconds;
|
this.seekOffset = seekSeconds;
|
||||||
this.framesPlayed = 0;
|
this.framesPlayed = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
this.spawnFailed = false;
|
||||||
|
|
||||||
// Prevent rapid-fire spawn attempts when ffmpeg is broken
|
|
||||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||||
this.logger.error(
|
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||||
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
|
|
||||||
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
|
|
||||||
);
|
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
|
this.emit("error", new Error("ffmpeg unavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
|
||||||
|
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
// BiliBili CDN requires Referer header for audio playback
|
|
||||||
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
||||||
args.push(
|
args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n");
|
||||||
"-headers",
|
|
||||||
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5");
|
||||||
args.push(
|
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
||||||
"-reconnect", "1",
|
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
|
||||||
"-reconnect_streamed", "1",
|
|
||||||
"-reconnect_delay_max", "5",
|
|
||||||
);
|
|
||||||
if (seekSeconds > 0) {
|
|
||||||
args.push("-ss", String(seekSeconds));
|
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
"-i", url,
|
|
||||||
"-f", "s16le",
|
|
||||||
"-ar", "48000",
|
|
||||||
"-ac", "2",
|
|
||||||
"-acodec", "pcm_s16le",
|
|
||||||
"-",
|
|
||||||
);
|
|
||||||
|
|
||||||
const ffmpegBin = getFfmpegCommand();
|
const ffmpegBin = getFfmpegCommand();
|
||||||
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
|
const currentPid = this.ffmpeg.pid;
|
||||||
this.ffmpeg.stdout!.on("error", (err) => {
|
if (currentPid) {
|
||||||
this.logger.warn({ err }, "FFmpeg stdout error");
|
globalActivePids.add(currentPid);
|
||||||
});
|
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
|
||||||
this.ffmpeg.stderr!.on("error", (err) => {
|
}
|
||||||
this.logger.warn({ err }, "FFmpeg stderr error");
|
|
||||||
});
|
|
||||||
|
|
||||||
let gotFirstData = false;
|
|
||||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||||
if (!gotFirstData) {
|
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
|
||||||
gotFirstData = true;
|
if (this.sessionId !== currentSessionId) {
|
||||||
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||||
// Backpressure: pause FFmpeg stdout when buffer is too large
|
|
||||||
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
||||||
this.ffmpeg.stdout.pause();
|
this.ffmpeg.stdout.pause();
|
||||||
this.ffmpegPaused = true;
|
this.ffmpegPaused = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ffmpeg.on("close", (code, signal) => {
|
this.ffmpeg.on("exit", (code, signal) => {
|
||||||
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
|
if (currentPid) globalActivePids.delete(currentPid);
|
||||||
if (this.sessionId === playSessionId) {
|
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||||
this.ffmpeg = null; // Signal frame loop that no more data is coming
|
|
||||||
|
// 只有当前会话的进程结束才置空变量
|
||||||
|
if (this.sessionId === currentSessionId) {
|
||||||
|
this.ffmpeg = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ffmpeg.on("error", (err) => {
|
this.ffmpeg.on("error", (err) => {
|
||||||
this.logger.error({ err }, "FFmpeg error");
|
if (this.sessionId === currentSessionId) {
|
||||||
if (this.sessionId === playSessionId) {
|
|
||||||
this.spawnFailed = true;
|
this.spawnFailed = true;
|
||||||
this.consecutiveFailures++;
|
this.consecutiveFailures++;
|
||||||
this.emit("error", err);
|
this.emit("error", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log FFmpeg stderr at info level for debugging playback issues
|
|
||||||
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
|
|
||||||
const msg = data.toString().trimEnd();
|
|
||||||
// Log important FFmpeg messages at info level
|
|
||||||
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
|
|
||||||
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
|
|
||||||
} else {
|
|
||||||
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state = "playing";
|
this.state = "playing";
|
||||||
this.startFrameLoop();
|
this.startFrameLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
// 3. 递增 ID 是最有效的逻辑“隔离墙”
|
||||||
|
this.sessionId++;
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
|
||||||
|
// 立即清空缓冲区,确保切歌瞬间静音 (
|
||||||
|
this.pcmBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
const procToKill = this.ffmpeg;
|
||||||
|
const pidToKill = procToKill.pid;
|
||||||
|
this.ffmpeg = null;
|
||||||
|
|
||||||
|
if (pidToKill) {
|
||||||
|
this.forceCleanup(procToKill, pidToKill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ffmpegPaused = false;
|
||||||
|
this.spawnFailed = false;
|
||||||
|
this.state = "idle";
|
||||||
|
this.currentUrl = "";
|
||||||
|
this.seekOffset = 0;
|
||||||
|
this.framesPlayed = 0;
|
||||||
|
this.healthyFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||||
|
if (!globalActivePids.has(pid)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
globalActivePids.delete(pid);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
proc.unref();
|
||||||
|
proc.once("exit", () => {
|
||||||
|
clearTimeout(killTimeout);
|
||||||
|
globalActivePids.delete(pid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private startFrameLoop(): void {
|
private startFrameLoop(): void {
|
||||||
if (this.frameLoopRunning) return;
|
if (this.frameLoopRunning) return;
|
||||||
this.frameLoopRunning = true;
|
this.frameLoopRunning = true;
|
||||||
@@ -224,50 +218,37 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
|
|
||||||
private scheduleNextFrame(): void {
|
private scheduleNextFrame(): void {
|
||||||
if (!this.frameLoopRunning) return;
|
if (!this.frameLoopRunning) return;
|
||||||
|
|
||||||
const loopSessionId = this.sessionId;
|
const loopSessionId = this.sessionId;
|
||||||
|
|
||||||
this.nextFrameTime += FRAME_DURATION_MS;
|
this.nextFrameTime += FRAME_DURATION_MS;
|
||||||
const now = performance.now();
|
const delay = Math.max(0, this.nextFrameTime - performance.now());
|
||||||
const delay = Math.max(0, this.nextFrameTime - now);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Discard callback from a stale play session
|
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
|
||||||
if (loopSessionId !== this.sessionId) return;
|
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
|
||||||
if (!this.frameLoopRunning) return;
|
|
||||||
|
|
||||||
if (this.state === "playing") {
|
if (this.state === "playing") this.sendNextFrame();
|
||||||
this.sendNextFrame();
|
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||||
} else if (this.state === "paused") {
|
|
||||||
this.nextFrameTime = performance.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
|
if (!this.spawnFailed) {
|
||||||
if (this.spawnFailed) {
|
this.consecutiveFailures = 0;
|
||||||
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
|
|
||||||
} else {
|
|
||||||
this.consecutiveFailures = 0; // Reset on successful track completion
|
|
||||||
this.emit("trackEnd");
|
this.emit("trackEnd");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleNextFrame();
|
this.scheduleNextFrame();
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendNextFrame(): void {
|
private sendNextFrame(): void {
|
||||||
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
||||||
|
|
||||||
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
||||||
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
||||||
|
|
||||||
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
|
|
||||||
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
|
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
|
||||||
this.ffmpeg.stdout.resume();
|
this.ffmpeg.stdout.resume();
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
@@ -278,101 +259,33 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
const opusFrame = this.encoder.encode(adjusted);
|
const opusFrame = this.encoder.encode(adjusted);
|
||||||
this.emit("frame", opusFrame);
|
this.emit("frame", opusFrame);
|
||||||
this.framesPlayed++;
|
this.framesPlayed++;
|
||||||
|
this.healthyFrames++;
|
||||||
if (this.framesPlayed === 1) {
|
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
|
||||||
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
|
this.consecutiveFailures = 0;
|
||||||
}
|
this.healthyFrames = 0;
|
||||||
// Log every ~10 seconds (500 frames * 20ms = 10s)
|
|
||||||
if (this.framesPlayed % 500 === 0) {
|
|
||||||
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ err }, "Error encoding/sending audio frame");
|
|
||||||
this.emit("error", err as Error);
|
this.emit("error", err as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(pcm: Buffer): Buffer {
|
private applyVolume(pcm: Buffer): Buffer {
|
||||||
if (this.volume === 100) return Buffer.from(pcm);
|
if (this.volume === 100) return Buffer.from(pcm);
|
||||||
const factor = this.volume / 100;
|
const factor = (this.volume / 100) * 0.2;
|
||||||
const out = Buffer.alloc(pcm.length);
|
const out = Buffer.alloc(pcm.length);
|
||||||
for (let i = 0; i < pcm.length; i += 2) {
|
for (let i = 0; i < pcm.length; i += 2) {
|
||||||
let sample = pcm.readInt16LE(i);
|
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||||
sample = Math.round(sample * factor);
|
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
|
||||||
if (sample > 32767) sample = 32767;
|
|
||||||
else if (sample < -32768) sample = -32768;
|
|
||||||
out.writeInt16LE(sample, i);
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actual elapsed time in seconds (ground truth from frame count) */
|
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
||||||
getElapsed(): number {
|
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
|
||||||
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
|
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
||||||
}
|
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
||||||
|
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||||
seek(seconds: number): void {
|
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
|
||||||
if (!this.currentUrl) return;
|
getVolume(): number { return this.volume; }
|
||||||
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
|
getState(): PlayerState { return this.state; }
|
||||||
// bad value here would poison seekOffset and leave getElapsed()
|
|
||||||
// returning NaN until the track ends.
|
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
||||||
this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.info({ seek: seconds }, "Seeking");
|
|
||||||
this.play(this.currentUrl, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSeekOffset(): number {
|
|
||||||
return this.seekOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
pause(): void {
|
|
||||||
if (this.state === "playing") {
|
|
||||||
this.state = "paused";
|
|
||||||
this.logger.debug("Playback paused");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resume(): void {
|
|
||||||
if (this.state === "paused") {
|
|
||||||
this.state = "playing";
|
|
||||||
this.nextFrameTime = performance.now();
|
|
||||||
this.logger.debug("Playback resumed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.sessionId++;
|
|
||||||
this.frameLoopRunning = false;
|
|
||||||
if (this.ffmpeg) {
|
|
||||||
this.ffmpeg.kill("SIGTERM");
|
|
||||||
this.ffmpeg = null;
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
getVolume(): number {
|
|
||||||
return this.volume;
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(): PlayerState {
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
|
|||||||
expect(next).not.toBeNull();
|
expect(next).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("random mode with single song returns null on next", () => {
|
||||||
|
queue.setMode(PlayMode.Random);
|
||||||
|
queue.add(makeSong("1"));
|
||||||
|
queue.play();
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("random mode plays each song exactly once then stops", () => {
|
||||||
|
queue.setMode(PlayMode.Random);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.play();
|
||||||
|
const played = new Set<string>();
|
||||||
|
played.add(queue.current()!.id);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const song = queue.next();
|
||||||
|
if (!song) break;
|
||||||
|
played.add(song.id);
|
||||||
|
}
|
||||||
|
// All 3 songs should have been played
|
||||||
|
expect(played).toEqual(new Set(["A", "B", "C"]));
|
||||||
|
// next() after all played should return null
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("random mode: removing currently-playing song does not skip others", () => {
|
||||||
|
queue.setMode(PlayMode.Random);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.add(makeSong("D"));
|
||||||
|
queue.play(); // plays A (index 0)
|
||||||
|
const second = queue.next()!; // plays some song
|
||||||
|
// Remove the currently-playing song
|
||||||
|
const curIdx = queue.getCurrentIndex();
|
||||||
|
queue.remove(curIdx);
|
||||||
|
// Remaining songs (excluding A and the removed song) should all be reachable
|
||||||
|
const played = new Set<string>();
|
||||||
|
played.add("A"); // already played via play()
|
||||||
|
played.add(second.id); // played and then removed
|
||||||
|
let song = queue.next();
|
||||||
|
while (song) {
|
||||||
|
played.add(song.id);
|
||||||
|
song = queue.next();
|
||||||
|
}
|
||||||
|
// All 4 original songs should have been played or accounted for
|
||||||
|
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("random mode: prev does not cause duplicate plays", () => {
|
||||||
|
queue.setMode(PlayMode.Random);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.play(); // plays A
|
||||||
|
queue.next(); // plays B or C
|
||||||
|
queue.prev(); // go back — this song is now marked as played
|
||||||
|
// Exhaust remaining songs
|
||||||
|
const ids: string[] = [];
|
||||||
|
let song = queue.next();
|
||||||
|
while (song) {
|
||||||
|
ids.push(song.id);
|
||||||
|
song = queue.next();
|
||||||
|
}
|
||||||
|
// No song ID should appear more than once across the entire session
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("random mode: adding song mid-playback includes the new song", () => {
|
||||||
|
queue.setMode(PlayMode.Random);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.play(); // plays A
|
||||||
|
queue.next(); // plays B
|
||||||
|
// Add a new song while all existing songs have been played
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
const song = queue.next();
|
||||||
|
expect(song).not.toBeNull();
|
||||||
|
expect(song!.id).toBe("C");
|
||||||
|
// After C, should stop
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("random mode: setMode preserves current song as played", () => {
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.play(); // plays A in sequential mode
|
||||||
|
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
|
||||||
|
// next() should only return B, never A again
|
||||||
|
const song = queue.next();
|
||||||
|
expect(song?.id).toBe("B");
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("random-loop mode never returns null", () => {
|
it("random-loop mode never returns null", () => {
|
||||||
queue.setMode(PlayMode.RandomLoop);
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
queue.add(makeSong("1"));
|
queue.add(makeSong("1"));
|
||||||
|
|||||||
+27
-12
@@ -20,6 +20,7 @@ export class PlayQueue {
|
|||||||
private songs: QueuedSong[] = [];
|
private songs: QueuedSong[] = [];
|
||||||
private currentIndex = -1;
|
private currentIndex = -1;
|
||||||
private mode: PlayMode = PlayMode.Sequential;
|
private mode: PlayMode = PlayMode.Sequential;
|
||||||
|
private playedIndices = new Set<number>();
|
||||||
|
|
||||||
add(song: QueuedSong): void {
|
add(song: QueuedSong): void {
|
||||||
this.songs.push(song);
|
this.songs.push(song);
|
||||||
@@ -36,33 +37,39 @@ export class PlayQueue {
|
|||||||
if (index < this.currentIndex) {
|
if (index < this.currentIndex) {
|
||||||
this.currentIndex--;
|
this.currentIndex--;
|
||||||
} else if (index === this.currentIndex) {
|
} else if (index === this.currentIndex) {
|
||||||
// Move the pointer back by one so next() in sequential mode advances
|
|
||||||
// to the song that shifted into the removed slot. Without this, the
|
|
||||||
// shifted song is silently skipped because current() incorrectly
|
|
||||||
// returns it (even though the player is still on the removed song)
|
|
||||||
// and next() then increments past it. currentIndex may become -1,
|
|
||||||
// which is fine — it represents "no current song" and next() will
|
|
||||||
// pick index 0.
|
|
||||||
this.currentIndex--;
|
this.currentIndex--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild playedIndices to account for shifted indices
|
||||||
|
const newPlayed = new Set<number>();
|
||||||
|
for (const idx of this.playedIndices) {
|
||||||
|
if (idx === index) continue;
|
||||||
|
newPlayed.add(idx > index ? idx - 1 : idx);
|
||||||
|
}
|
||||||
|
this.playedIndices = newPlayed;
|
||||||
|
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.songs = [];
|
this.songs = [];
|
||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
|
this.playedIndices.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): QueuedSong | null {
|
play(): QueuedSong | null {
|
||||||
if (this.songs.length === 0) return null;
|
if (this.songs.length === 0) return null;
|
||||||
|
this.playedIndices.clear();
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
|
this.playedIndices.add(0);
|
||||||
return this.songs[0];
|
return this.songs[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
playAt(index: number): QueuedSong | null {
|
playAt(index: number): QueuedSong | null {
|
||||||
if (index < 0 || index >= this.songs.length) return null;
|
if (index < 0 || index >= this.songs.length) return null;
|
||||||
|
this.playedIndices.clear();
|
||||||
this.currentIndex = index;
|
this.currentIndex = index;
|
||||||
|
this.playedIndices.add(index);
|
||||||
return this.songs[index];
|
return this.songs[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +88,15 @@ export class PlayQueue {
|
|||||||
return this.songs[this.currentIndex];
|
return this.songs[this.currentIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.Random: {
|
case PlayMode.Random: {
|
||||||
if (this.songs.length === 1) return this.songs[0];
|
const unplayed: number[] = [];
|
||||||
let nextIndex: number;
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
do {
|
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
}
|
||||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
if (unplayed.length === 0) return null;
|
||||||
|
const nextIndex =
|
||||||
|
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||||
this.currentIndex = nextIndex;
|
this.currentIndex = nextIndex;
|
||||||
|
this.playedIndices.add(nextIndex);
|
||||||
return this.songs[nextIndex];
|
return this.songs[nextIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.RandomLoop: {
|
case PlayMode.RandomLoop: {
|
||||||
@@ -114,6 +124,7 @@ export class PlayQueue {
|
|||||||
} else {
|
} else {
|
||||||
this.currentIndex = prevIndex;
|
this.currentIndex = prevIndex;
|
||||||
}
|
}
|
||||||
|
this.playedIndices.add(this.currentIndex);
|
||||||
return this.songs[this.currentIndex];
|
return this.songs[this.currentIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +152,10 @@ export class PlayQueue {
|
|||||||
|
|
||||||
setMode(mode: PlayMode): void {
|
setMode(mode: PlayMode): void {
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
|
this.playedIndices.clear();
|
||||||
|
if (this.currentIndex >= 0) {
|
||||||
|
this.playedIndices.add(this.currentIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentIndex(): number {
|
getCurrentIndex(): number {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface ParsedCommand {
|
|||||||
export const PUBLIC_COMMANDS = new Set([
|
export const PUBLIC_COMMANDS = new Set([
|
||||||
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
|
||||||
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
|
||||||
|
"artist",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ADMIN_COMMANDS = new Set([
|
export const ADMIN_COMMANDS = new Set([
|
||||||
|
|||||||
Regular → Executable
+191
-5
@@ -13,8 +13,9 @@ import {
|
|||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,6 +61,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
private disconnectEmitted = false;
|
private disconnectEmitted = false;
|
||||||
private voteSkipUsers = new Set<string>();
|
private voteSkipUsers = new Set<string>();
|
||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private channelUserCount = 0;
|
||||||
|
private profileManager: BotProfileManager;
|
||||||
|
private isFmMode = false;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -77,6 +82,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
this.queue = new PlayQueue();
|
this.queue = new PlayQueue();
|
||||||
|
|
||||||
|
const profileConfig = this.database.getProfileConfig(this.id);
|
||||||
|
this.profileManager = new BotProfileManager(
|
||||||
|
this.tsClient,
|
||||||
|
this.logger,
|
||||||
|
profileConfig,
|
||||||
|
options.tsOptions.nickname,
|
||||||
|
);
|
||||||
|
|
||||||
this.setupPlayerEvents();
|
this.setupPlayerEvents();
|
||||||
this.setupTsEvents();
|
this.setupTsEvents();
|
||||||
}
|
}
|
||||||
@@ -121,6 +134,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.disconnectEmitted = true;
|
this.disconnectEmitted = true;
|
||||||
this.emit("disconnected");
|
this.emit("disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.tsClient.on("connected", () => {
|
||||||
|
this._startIdlePoller();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -134,10 +151,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
throw new Error("Connect aborted by concurrent disconnect");
|
throw new Error("Connect aborted by concurrent disconnect");
|
||||||
}
|
}
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
this.profileManager.onConnect();
|
||||||
this.emit("connected");
|
this.emit("connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this._cancelIdleTimer();
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
if (!this.disconnectEmitted) {
|
if (!this.disconnectEmitted) {
|
||||||
@@ -147,6 +166,48 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.tsClient.disconnect();
|
this.tsClient.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
|
||||||
|
updateIdleTimeout(minutes: number): void {
|
||||||
|
this.config.idleTimeoutMinutes = minutes;
|
||||||
|
if (minutes === 0) this._cancelIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _startIdlePoller(): void {
|
||||||
|
// 每 30 秒检查一次频道人数
|
||||||
|
const poll = async () => {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try {
|
||||||
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
|
const userCount = clients.length - 1; // 排除 bot 自身
|
||||||
|
if (userCount <= 0) {
|
||||||
|
this._scheduleIdleCheck();
|
||||||
|
} else {
|
||||||
|
this._cancelIdleTimer();
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setTimeout(poll, 30_000);
|
||||||
|
};
|
||||||
|
setTimeout(poll, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleIdleCheck(): void {
|
||||||
|
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||||
|
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||||
|
if (!this.connected || minutes <= 0) return;
|
||||||
|
this.idleTimer = setTimeout(() => {
|
||||||
|
if (!this.connected) return;
|
||||||
|
this.logger.info({ idleMinutes: minutes }, "Channel empty, disconnecting due to idle timeout");
|
||||||
|
this.disconnect();
|
||||||
|
}, minutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancelIdleTimer(): void {
|
||||||
|
if (this.idleTimer) {
|
||||||
|
clearTimeout(this.idleTimer);
|
||||||
|
this.idleTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
||||||
const parsed = parseCommand(
|
const parsed = parseCommand(
|
||||||
msg.message,
|
msg.message,
|
||||||
@@ -199,6 +260,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
"playlist",
|
"playlist",
|
||||||
"album",
|
"album",
|
||||||
"fm",
|
"fm",
|
||||||
|
"artist",
|
||||||
]);
|
]);
|
||||||
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
|
if (!this.connected && AUDIO_COMMANDS.has(cmd.name)) {
|
||||||
throw new Error("Bot is not connected to TeamSpeak");
|
throw new Error("Bot is not connected to TeamSpeak");
|
||||||
@@ -238,6 +300,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.cmdAlbum(cmd);
|
return this.cmdAlbum(cmd);
|
||||||
case "fm":
|
case "fm":
|
||||||
return this.cmdFm();
|
return this.cmdFm();
|
||||||
|
case "artist":
|
||||||
|
return this.cmdArtist(cmd);
|
||||||
case "vote":
|
case "vote":
|
||||||
return this.cmdVote(msg);
|
return this.cmdVote(msg);
|
||||||
case "lyrics":
|
case "lyrics":
|
||||||
@@ -306,6 +370,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
coverUrl: song.coverUrl,
|
||||||
});
|
});
|
||||||
|
// Update bot presence (fire-and-forget — never blocks playback)
|
||||||
|
this.profileManager.onSongChange(song).catch((err) => {
|
||||||
|
this.logger.warn({ err }, "Profile update failed after song change");
|
||||||
|
});
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -323,6 +391,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
@@ -374,6 +443,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
|
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||||
|
});
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Stopped and queue cleared";
|
return "Stopped and queue cleared";
|
||||||
}
|
}
|
||||||
@@ -424,6 +497,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
|
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||||
|
});
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Queue cleared";
|
return "Queue cleared";
|
||||||
}
|
}
|
||||||
@@ -452,13 +529,48 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !playlist <playlist ID or URL>";
|
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
|
||||||
|
// Determine if input is a numeric ID or a name search
|
||||||
const id = this.extractId(cmd.args);
|
const id = this.extractId(cmd.args);
|
||||||
const songs = await provider.getPlaylistSongs(id);
|
const isNumericId = /^\d+$/.test(cmd.args.trim());
|
||||||
|
|
||||||
|
let playlistId: string;
|
||||||
|
|
||||||
|
if (isNumericId || id !== cmd.args) {
|
||||||
|
// Input is a numeric ID or URL containing an ID — use existing logic
|
||||||
|
playlistId = id;
|
||||||
|
} else {
|
||||||
|
// Name-based search
|
||||||
|
const result = await provider.search(cmd.args);
|
||||||
|
let playlists = result.playlists ?? [];
|
||||||
|
|
||||||
|
// Also search user's personal playlists if logged in
|
||||||
|
if (provider.getUserPlaylists) {
|
||||||
|
try {
|
||||||
|
const userPlaylists = await provider.getUserPlaylists();
|
||||||
|
const query = cmd.args.toLowerCase();
|
||||||
|
const matched = userPlaylists.filter(
|
||||||
|
p => p.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
// Merge: public results first (API-ranked), then user matches
|
||||||
|
playlists = [...playlists, ...matched];
|
||||||
|
} catch {
|
||||||
|
// User playlists unavailable — continue with public results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.length === 0)
|
||||||
|
return `No playlists found for: ${cmd.args}`;
|
||||||
|
playlistId = playlists[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await provider.getPlaylistSongs(playlistId);
|
||||||
if (songs.length === 0) return "Playlist is empty or not found";
|
if (songs.length === 0) return "Playlist is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -475,6 +587,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (songs.length === 0) return "Album is empty or not found";
|
if (songs.length === 0) return "Album is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -496,12 +609,61 @@ export class BotInstance extends EventEmitter {
|
|||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
}
|
}
|
||||||
|
this.queue.setMode(PlayMode.RandomLoop);
|
||||||
|
this.isFmMode = true;
|
||||||
|
this.player.resetFailures();
|
||||||
|
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||||
|
if (!cmd.args) return "Usage: !artist <artist name>";
|
||||||
|
const provider = this.getProvider(cmd.flags);
|
||||||
|
const result = await provider.search(cmd.args, 50);
|
||||||
|
if (result.songs.length === 0)
|
||||||
|
return `No results found for artist: ${cmd.args}`;
|
||||||
|
|
||||||
|
const query = cmd.args.toLowerCase();
|
||||||
|
let filtered = result.songs.filter(
|
||||||
|
s => s.artist.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback to unfiltered results if filtering drops everything
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
filtered = result.songs.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.clear();
|
||||||
|
this.isFmMode = false;
|
||||||
|
for (const song of filtered) {
|
||||||
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
|
}
|
||||||
|
this.queue.setMode(PlayMode.Loop);
|
||||||
|
this.player.resetFailures();
|
||||||
|
|
||||||
|
const first = this.queue.play();
|
||||||
|
if (first) await this.resolveAndPlay(first);
|
||||||
|
this.emit("stateChange");
|
||||||
|
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refillFm(): Promise<void> {
|
||||||
|
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
||||||
|
try {
|
||||||
|
const songs = await this.neteaseProvider.getPersonalFm();
|
||||||
|
if (songs.length === 0) return;
|
||||||
|
for (const song of songs) {
|
||||||
|
this.queue.add({ ...song, platform: "netease" });
|
||||||
|
}
|
||||||
|
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ err }, "Failed to refill FM queue");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
|
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
|
||||||
if (!msg) return "Vote can only be used in TeamSpeak";
|
if (!msg) return "Vote can only be used in TeamSpeak";
|
||||||
this.voteSkipUsers.add(msg.invokerUid);
|
this.voteSkipUsers.add(msg.invokerUid);
|
||||||
@@ -559,9 +721,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}vol <0-100> — Set volume`,
|
`${p}vol <0-100> — Set volume`,
|
||||||
`${p}queue — Show queue`,
|
`${p}queue — Show queue`,
|
||||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
||||||
`${p}playlist <id> — Load playlist`,
|
`${p}playlist <name or id> — Load playlist by name or ID`,
|
||||||
|
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
|
||||||
`${p}album <id> — Load album`,
|
`${p}album <id> — Load album`,
|
||||||
`${p}fm — Personal FM (NetEase)`,
|
`${p}fm — Personal FM (NetEase)`,
|
||||||
|
`${p}artist <name> — Play songs by artist (loop)`,
|
||||||
|
`${p}artist -q <name> — Artist loop from QQ Music`,
|
||||||
`${p}vote — Vote to skip`,
|
`${p}vote — Vote to skip`,
|
||||||
`${p}lyrics — Show lyrics`,
|
`${p}lyrics — Show lyrics`,
|
||||||
`${p}now — Current song info`,
|
`${p}now — Current song info`,
|
||||||
@@ -590,9 +755,26 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (!started) {
|
if (!started) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
|
||||||
|
// Proactive refill: when queue is running low, fetch more FM songs
|
||||||
|
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.player.stop();
|
// Defensive: only reached if play mode is changed away from RandomLoop during FM
|
||||||
|
if (this.isFmMode) {
|
||||||
|
await this.refillFm();
|
||||||
|
const refillNext = this.queue.next();
|
||||||
|
if (refillNext) {
|
||||||
|
await this.resolveAndPlay(refillNext);
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -639,6 +821,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProfileManager(): BotProfileManager {
|
||||||
|
return this.profileManager;
|
||||||
|
}
|
||||||
|
|
||||||
getIdentityExport(): string | undefined {
|
getIdentityExport(): string | undefined {
|
||||||
return this.tsClient.getIdentityExport();
|
return this.tsClient.getIdentityExport();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import axios from "axios";
|
||||||
|
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
||||||
|
import { HttpQueryError } from "../ts-protocol/http-query.js";
|
||||||
|
import type { ProfileConfig } from "../data/database.js";
|
||||||
|
import type { QueuedSong } from "../audio/queue.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
|
const TS3_NICKNAME_MAX = 30;
|
||||||
|
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
|
||||||
|
const AVATAR_MAX_BYTES = 200 * 1024;
|
||||||
|
/** Timeout for file-transfer operations (upload / delete). */
|
||||||
|
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||||
|
* away status, channel description, now-playing messages).
|
||||||
|
*
|
||||||
|
* Every update is permission-safe: if a feature fails due to insufficient
|
||||||
|
* server permissions, it silently disables itself until the next reconnect.
|
||||||
|
*/
|
||||||
|
export class BotProfileManager {
|
||||||
|
private tsClient: TS3Client;
|
||||||
|
private logger: Logger;
|
||||||
|
private config: ProfileConfig;
|
||||||
|
private defaultNickname: string;
|
||||||
|
|
||||||
|
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||||
|
private permDenied = {
|
||||||
|
avatar: false,
|
||||||
|
description: false,
|
||||||
|
nickname: false,
|
||||||
|
awayStatus: false,
|
||||||
|
channelDesc: false,
|
||||||
|
nowPlayingMsg: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonically increasing generation counter. Incremented on every
|
||||||
|
* onSongChange / onConnect call. Long-running operations (avatar
|
||||||
|
* download/upload) check this before committing their result — if
|
||||||
|
* the generation changed, a newer update has superseded them.
|
||||||
|
*/
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
tsClient: TS3Client,
|
||||||
|
logger: Logger,
|
||||||
|
config: ProfileConfig,
|
||||||
|
defaultNickname: string,
|
||||||
|
) {
|
||||||
|
this.tsClient = tsClient;
|
||||||
|
this.logger = logger.child({ component: "profile" });
|
||||||
|
this.config = { ...config };
|
||||||
|
this.defaultNickname = defaultNickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a new song starts playing (song != null) or playback
|
||||||
|
* stops (song == null).
|
||||||
|
*
|
||||||
|
* Commands are serialized to avoid overwhelming the TS3 command queue.
|
||||||
|
* Nickname + away status are merged into a single `clientupdate` call.
|
||||||
|
*
|
||||||
|
* A generation counter guards against stale updates: if a newer
|
||||||
|
* onSongChange fires while the avatar is still downloading, the old
|
||||||
|
* update is discarded.
|
||||||
|
*/
|
||||||
|
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||||
|
const gen = ++this.generation;
|
||||||
|
|
||||||
|
// 1. Avatar first — file transfer uses its own response tracker and
|
||||||
|
// must run before sendCommandNoWait calls whose orphaned responses
|
||||||
|
// could confuse the command matcher.
|
||||||
|
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||||
|
if (this.generation !== gen) return; // superseded
|
||||||
|
|
||||||
|
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||||
|
await this.updateClientProperties(song);
|
||||||
|
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||||
|
await this.updateDescription(song);
|
||||||
|
// 4. Channel description (fire-and-forget channeledit)
|
||||||
|
await this.updateChannelDescription(song);
|
||||||
|
// 5. Now-playing chat message
|
||||||
|
if (song) await this.sendNowPlayingMessage(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset permission-denied flags and bump generation on new connection. */
|
||||||
|
onConnect(): void {
|
||||||
|
this.generation++;
|
||||||
|
this.permDenied = {
|
||||||
|
avatar: false,
|
||||||
|
description: false,
|
||||||
|
nickname: false,
|
||||||
|
awayStatus: false,
|
||||||
|
channelDesc: false,
|
||||||
|
nowPlayingMsg: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfig(): ProfileConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(partial: Partial<ProfileConfig>): void {
|
||||||
|
Object.assign(this.config, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal update methods ---
|
||||||
|
|
||||||
|
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
|
||||||
|
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
|
||||||
|
try {
|
||||||
|
if (!coverUrl) {
|
||||||
|
await this.clearAvatar(gen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
|
||||||
|
const thumbUrl = this.thumbnailUrl(coverUrl);
|
||||||
|
const imageBuffer = await this.downloadImage(thumbUrl);
|
||||||
|
|
||||||
|
// Check generation after the slow download — bail if superseded.
|
||||||
|
if (this.generation !== gen) return;
|
||||||
|
|
||||||
|
if (!imageBuffer || imageBuffer.length === 0) return;
|
||||||
|
if (imageBuffer.length > AVATAR_MAX_BYTES) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
|
||||||
|
"Cover image still too large after resize — skipping avatar update",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the file-transfer sequence with a timeout — the TS3
|
||||||
|
// full-client file transfer can silently hang.
|
||||||
|
const start = Date.now();
|
||||||
|
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
||||||
|
this.logger.info(
|
||||||
|
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
|
||||||
|
"Avatar updated",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("avatar", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-step upload. Each step is logged so the log can tell us whether
|
||||||
|
* a broken/loading avatar on the client is from:
|
||||||
|
* (a) init failing (no permission)
|
||||||
|
* (b) file transfer hanging on TCP 30033
|
||||||
|
* (c) client_flag_avatar not applying
|
||||||
|
* If (b) happens, the avatar MD5 would still be set in the past — leaving
|
||||||
|
* clients showing a placeholder. The flag is now only set after the TCP
|
||||||
|
* transfer resolves.
|
||||||
|
*/
|
||||||
|
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
||||||
|
const host = this.tsClient.getHost();
|
||||||
|
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
|
||||||
|
const info = await this.tsClient.fileTransferInitUpload(
|
||||||
|
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
||||||
|
);
|
||||||
|
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
|
||||||
|
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
||||||
|
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
||||||
|
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
|
||||||
|
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearAvatar(gen: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.withTimeout(
|
||||||
|
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||||
|
FILE_TRANSFER_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// File may not exist or transfer timed out — that's fine
|
||||||
|
}
|
||||||
|
// Bail if a newer song started while we were deleting
|
||||||
|
if (this.generation !== gen) return;
|
||||||
|
try {
|
||||||
|
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("avatar", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||||
|
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||||
|
try {
|
||||||
|
const text = song
|
||||||
|
? `${song.name} - ${song.artist} [${song.album}]`
|
||||||
|
: "";
|
||||||
|
const httpQuery = this.tsClient.getHttpQuery();
|
||||||
|
if (httpQuery) {
|
||||||
|
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
|
||||||
|
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
|
||||||
|
// be misreported as success.
|
||||||
|
const result = await httpQuery.clientUpdate({ client_description: text });
|
||||||
|
this.logger.info({ status: result.status }, "Description updated");
|
||||||
|
} else {
|
||||||
|
// clientupdate rejects client_description (error 1538).
|
||||||
|
// Use clientedit on our own clid instead — this is what
|
||||||
|
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||||
|
const clid = this.tsClient.getClientId();
|
||||||
|
if (clid <= 0) return;
|
||||||
|
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||||
|
// remaining profile updates (channeledit, now-playing msg).
|
||||||
|
await this.withTimeout(
|
||||||
|
this.tsClient.execCommand(
|
||||||
|
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||||
|
),
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
this.logger.info("Description updated");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("description", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and send a single `clientupdate` command that sets nickname
|
||||||
|
* and away status together, avoiding multiple round-trips that can
|
||||||
|
* cause command-queue timeouts on the TS3 protocol.
|
||||||
|
*
|
||||||
|
* Values are collected as raw strings/numbers. The TS6 HTTP path
|
||||||
|
* forwards them as JSON (the server expects real spaces, not `\s`);
|
||||||
|
* the TS3 wire path escapes them on the fly. Previously the code
|
||||||
|
* escaped upfront and then split the escaped string to build the
|
||||||
|
* JSON body, so TS6 received literal backslashes and silently
|
||||||
|
* rejected the update.
|
||||||
|
*/
|
||||||
|
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||||
|
const rawProps: Record<string, string | number> = {};
|
||||||
|
|
||||||
|
// --- Nickname ---
|
||||||
|
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
||||||
|
if (!song) {
|
||||||
|
rawProps.client_nickname = this.defaultNickname;
|
||||||
|
} else {
|
||||||
|
const nickname = this.buildNickname(song);
|
||||||
|
if (nickname) {
|
||||||
|
rawProps.client_nickname = nickname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Away status ---
|
||||||
|
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
||||||
|
if (song) {
|
||||||
|
rawProps.client_away = 0;
|
||||||
|
} else {
|
||||||
|
rawProps.client_away = 1;
|
||||||
|
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(rawProps).length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const httpQuery = this.tsClient.getHttpQuery();
|
||||||
|
if (httpQuery) {
|
||||||
|
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
|
||||||
|
const result = await httpQuery.clientUpdate(rawProps);
|
||||||
|
this.logger.info(
|
||||||
|
{ status: result.status, props: Object.keys(rawProps) },
|
||||||
|
"Client properties updated (nickname + away)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// TS3 wire protocol: escape string values inline.
|
||||||
|
// sendCommandNoWait: the TS3 full-client protocol often
|
||||||
|
// doesn't return a timely error response for clientupdate,
|
||||||
|
// causing execCommand to time out after 10s.
|
||||||
|
const parts = Object.entries(rawProps).map(([k, v]) =>
|
||||||
|
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
|
||||||
|
);
|
||||||
|
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||||
|
this.logger.info(
|
||||||
|
{ props: Object.keys(rawProps) },
|
||||||
|
"Client properties updated (nickname + away)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Flag both features on permission error
|
||||||
|
this.handleFeatureError("nickname", err);
|
||||||
|
this.handleFeatureError("awayStatus", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a nickname string that fits within TS3_NICKNAME_MAX.
|
||||||
|
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
|
||||||
|
* not characters.
|
||||||
|
*/
|
||||||
|
private buildNickname(song: QueuedSong): string | null {
|
||||||
|
const songInfo = `${song.name} - ${song.artist}`;
|
||||||
|
const prefix = "\u266A "; // ♪
|
||||||
|
const sep = " - ";
|
||||||
|
const suffix = `${sep}${this.defaultNickname}`;
|
||||||
|
|
||||||
|
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
|
||||||
|
if (overheadBytes >= TS3_NICKNAME_MAX) {
|
||||||
|
// Default nickname alone is too long with decoration — skip
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
|
||||||
|
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
|
||||||
|
return `${prefix}${truncated}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
|
||||||
|
* Appends an ellipsis if truncation occurred, taking its byte cost
|
||||||
|
* into account. Never splits a multi-byte character.
|
||||||
|
*/
|
||||||
|
private truncateUtf8(str: string, maxBytes: number): string {
|
||||||
|
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
|
||||||
|
const ellipsis = "\u2026"; // …
|
||||||
|
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
|
||||||
|
const target = maxBytes - ellipsisBytes;
|
||||||
|
if (target <= 0) return ellipsis;
|
||||||
|
// Walk characters, accumulating byte length
|
||||||
|
let byteLen = 0;
|
||||||
|
let end = 0;
|
||||||
|
for (const ch of str) {
|
||||||
|
const chBytes = Buffer.byteLength(ch, "utf8");
|
||||||
|
if (byteLen + chBytes > target) break;
|
||||||
|
byteLen += chBytes;
|
||||||
|
end += ch.length; // ch.length handles surrogate pairs
|
||||||
|
}
|
||||||
|
return str.slice(0, end) + ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||||
|
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||||
|
try {
|
||||||
|
const channelId = this.tsClient.getChannelId();
|
||||||
|
if (channelId === 0n) return; // unknown channel
|
||||||
|
|
||||||
|
if (!song) {
|
||||||
|
await this.tsClient.sendCommandNoWait(
|
||||||
|
`channeledit cid=${channelId} channel_description=`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||||
|
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||||
|
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||||
|
];
|
||||||
|
const desc = lines.join("\\n");
|
||||||
|
await this.tsClient.sendCommandNoWait(
|
||||||
|
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("channelDesc", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||||
|
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||||
|
try {
|
||||||
|
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
|
||||||
|
await this.tsClient.sendTextMessage(text);
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("nowPlayingMsg", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||||
|
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||||
|
* BiliBili and YouTube covers fall through to the size-check guard.
|
||||||
|
*/
|
||||||
|
private thumbnailUrl(url: string): string {
|
||||||
|
if (url.includes("music.126.net") || url.includes("netease")) {
|
||||||
|
return url.includes("?") ? url : `${url}?param=200y200`;
|
||||||
|
}
|
||||||
|
if (url.includes("qqmusic") || url.includes("qq.com")) {
|
||||||
|
return url.replace(/\/\d+$/, "/200");
|
||||||
|
}
|
||||||
|
if (url.includes("bilivideo") || url.includes("hdslb")) {
|
||||||
|
// BiliBili CDN supports @<w>w_<h>h suffix
|
||||||
|
return url.includes("@") ? url : `${url}@200w_200h`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadImage(url: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(url, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout: 8000,
|
||||||
|
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
|
||||||
|
});
|
||||||
|
return Buffer.from(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err, url }, "Failed to download cover image");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Race a promise against a timeout. */
|
||||||
|
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFeatureError(
|
||||||
|
feature: keyof typeof this.permDenied,
|
||||||
|
err: unknown,
|
||||||
|
): void {
|
||||||
|
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||||
|
const status = err instanceof HttpQueryError ? err.status : undefined;
|
||||||
|
const body = err instanceof HttpQueryError ? err.body : undefined;
|
||||||
|
// Disable the feature for this session on unrecoverable errors:
|
||||||
|
// - permission / insufficient → server denies the action
|
||||||
|
// - invalid parameter → command not supported by this protocol
|
||||||
|
// - HTTP 401/403 → TS6 server rejects the API key/role
|
||||||
|
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
|
||||||
|
const isUnrecoverable =
|
||||||
|
msg.includes("permission") ||
|
||||||
|
msg.includes("insufficient") ||
|
||||||
|
msg.includes("invalid parameter") ||
|
||||||
|
status === 400 ||
|
||||||
|
status === 401 ||
|
||||||
|
status === 403;
|
||||||
|
if (isUnrecoverable) {
|
||||||
|
this.permDenied[feature] = true;
|
||||||
|
this.logger.info(
|
||||||
|
{ feature, status, body, reason: msg },
|
||||||
|
"Feature disabled for this session (will retry after reconnect)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn({ feature, status, body, err }, "Profile update failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+12
@@ -13,6 +13,15 @@ export interface BotConfig {
|
|||||||
adminGroups: number[];
|
adminGroups: number[];
|
||||||
autoReturnDelay: number;
|
autoReturnDelay: number;
|
||||||
autoPauseOnEmpty: boolean;
|
autoPauseOnEmpty: boolean;
|
||||||
|
idleTimeoutMinutes: number;
|
||||||
|
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||||
|
// Leave empty to use the browser's current origin. Example:
|
||||||
|
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||||
|
publicUrl: string;
|
||||||
|
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
|
||||||
|
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
|
||||||
|
// behind HTTPS-terminating proxies.
|
||||||
|
trustProxy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultConfig(): BotConfig {
|
export function getDefaultConfig(): BotConfig {
|
||||||
@@ -28,6 +37,9 @@ export function getDefaultConfig(): BotConfig {
|
|||||||
adminGroups: [],
|
adminGroups: [],
|
||||||
autoReturnDelay: 300,
|
autoReturnDelay: 300,
|
||||||
autoPauseOnEmpty: true,
|
autoPauseOnEmpty: true,
|
||||||
|
idleTimeoutMinutes: 0,
|
||||||
|
publicUrl: "",
|
||||||
|
trustProxy: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ export interface BotInstance {
|
|||||||
identity?: string;
|
identity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileConfig {
|
||||||
|
avatarEnabled: boolean;
|
||||||
|
descriptionEnabled: boolean;
|
||||||
|
nicknameEnabled: boolean;
|
||||||
|
awayStatusEnabled: boolean;
|
||||||
|
channelDescEnabled: boolean;
|
||||||
|
nowPlayingMsgEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
||||||
|
avatarEnabled: true,
|
||||||
|
descriptionEnabled: true,
|
||||||
|
nicknameEnabled: true,
|
||||||
|
awayStatusEnabled: true,
|
||||||
|
channelDescEnabled: true,
|
||||||
|
nowPlayingMsgEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
export interface BotDatabase {
|
export interface BotDatabase {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||||
@@ -40,6 +58,8 @@ export interface BotDatabase {
|
|||||||
saveBotInstance(instance: BotInstance): void;
|
saveBotInstance(instance: BotInstance): void;
|
||||||
getBotInstances(): BotInstance[];
|
getBotInstances(): BotInstance[];
|
||||||
deleteBotInstance(id: string): boolean;
|
deleteBotInstance(id: string): boolean;
|
||||||
|
getProfileConfig(botId: string): ProfileConfig;
|
||||||
|
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
|
|||||||
if (!names.includes("serverPassword")) {
|
if (!names.includes("serverPassword")) {
|
||||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
||||||
}
|
}
|
||||||
|
// Profile feature flags
|
||||||
|
const profileCols = [
|
||||||
|
"profile_avatar_enabled",
|
||||||
|
"profile_description_enabled",
|
||||||
|
"profile_nickname_enabled",
|
||||||
|
"profile_away_enabled",
|
||||||
|
"profile_channel_desc_enabled",
|
||||||
|
"profile_now_playing_enabled",
|
||||||
|
];
|
||||||
|
for (const col of profileCols) {
|
||||||
|
if (!names.includes(col)) {
|
||||||
|
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTables(db: Database.Database): void {
|
function initTables(db: Database.Database): void {
|
||||||
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
|
|
||||||
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
||||||
|
|
||||||
|
const selectProfileConfig = db.prepare(`
|
||||||
|
SELECT profile_avatar_enabled, profile_description_enabled,
|
||||||
|
profile_nickname_enabled, profile_away_enabled,
|
||||||
|
profile_channel_desc_enabled, profile_now_playing_enabled
|
||||||
|
FROM bot_instances WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateProfileConfig = db.prepare(`
|
||||||
|
UPDATE bot_instances SET
|
||||||
|
profile_avatar_enabled = @avatar,
|
||||||
|
profile_description_enabled = @description,
|
||||||
|
profile_nickname_enabled = @nickname,
|
||||||
|
profile_away_enabled = @away,
|
||||||
|
profile_channel_desc_enabled = @channelDesc,
|
||||||
|
profile_now_playing_enabled = @nowPlaying
|
||||||
|
WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getProfileConfig(botId) {
|
||||||
|
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
|
||||||
|
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
|
||||||
|
return {
|
||||||
|
avatarEnabled: row.profile_avatar_enabled === 1,
|
||||||
|
descriptionEnabled: row.profile_description_enabled === 1,
|
||||||
|
nicknameEnabled: row.profile_nickname_enabled === 1,
|
||||||
|
awayStatusEnabled: row.profile_away_enabled === 1,
|
||||||
|
channelDescEnabled: row.profile_channel_desc_enabled === 1,
|
||||||
|
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
saveProfileConfig(botId, config) {
|
||||||
|
updateProfileConfig.run({
|
||||||
|
id: botId,
|
||||||
|
avatar: config.avatarEnabled ? 1 : 0,
|
||||||
|
description: config.descriptionEnabled ? 1 : 0,
|
||||||
|
nickname: config.nicknameEnabled ? 1 : 0,
|
||||||
|
away: config.awayStatusEnabled ? 1 : 0,
|
||||||
|
channelDesc: config.channelDescEnabled ? 1 : 0,
|
||||||
|
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
Regular → Executable
+5
-1
@@ -71,6 +71,7 @@ async function main() {
|
|||||||
bilibiliProvider,
|
bilibiliProvider,
|
||||||
database: db,
|
database: db,
|
||||||
config,
|
config,
|
||||||
|
configPath: CONFIG_PATH,
|
||||||
logger,
|
logger,
|
||||||
cookieStore,
|
cookieStore,
|
||||||
staticDir: STATIC_DIR,
|
staticDir: STATIC_DIR,
|
||||||
@@ -78,7 +79,10 @@ async function main() {
|
|||||||
await webServer.start();
|
await webServer.start();
|
||||||
|
|
||||||
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
||||||
logger.info(`WebUI: http://localhost:${config.webPort}`);
|
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
|
||||||
|
logger.info(
|
||||||
|
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
|
||||||
|
);
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger.info("Shutting down...");
|
logger.info("Shutting down...");
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ export function createApiServerManager(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||||
const koaApp = qqModule.default ?? qqModule;
|
// The module's export structure varies between versions:
|
||||||
|
// 2.2.11+: default → Koa app (has .listen)
|
||||||
|
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||||
|
// older: module itself may be the Koa app
|
||||||
|
const candidate = qqModule.default ?? qqModule;
|
||||||
|
const koaApp = typeof candidate.listen === "function"
|
||||||
|
? candidate
|
||||||
|
: candidate.default ?? null;
|
||||||
if (koaApp && typeof koaApp.listen === "function") {
|
if (koaApp && typeof koaApp.listen === "function") {
|
||||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||||
|
|||||||
+80
-23
@@ -10,6 +10,14 @@ import type {
|
|||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
|
// Direct QQ Music API client — bypasses the local API server for search
|
||||||
|
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
|
||||||
|
const qqDirectApi = axios.create({
|
||||||
|
baseURL: "https://u.y.qq.com",
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { referer: "https://y.qq.com" },
|
||||||
|
});
|
||||||
|
|
||||||
export class QQMusicProvider implements MusicProvider {
|
export class QQMusicProvider implements MusicProvider {
|
||||||
readonly platform = "qq" as const;
|
readonly platform = "qq" as const;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -36,33 +44,58 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
const res = await this.api.get("/getSearchByKey", {
|
const reqData = JSON.stringify({
|
||||||
params: { key: query, pageSize: limit, ...this.cookieParams },
|
req_0: {
|
||||||
|
module: "music.search.SearchCgiService",
|
||||||
|
method: "DoSearchForQQMusicDesktop",
|
||||||
|
param: {
|
||||||
|
searchid: "1",
|
||||||
|
query,
|
||||||
|
num_per_page: Math.min(limit, 50),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
|
||||||
|
params: { format: "json", data: reqData },
|
||||||
|
});
|
||||||
|
const list: any[] =
|
||||||
|
res.data?.req_0?.data?.body?.song?.list ?? [];
|
||||||
|
|
||||||
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
|
const songs: Song[] = list.map((s: any) => ({
|
||||||
(s: any) => ({
|
id: String(s.mid ?? s.id),
|
||||||
id: String(s.songmid ?? s.songid),
|
name: s.title ?? s.name ?? "",
|
||||||
name: s.songname ?? "",
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
album: s.album?.name ?? s.album?.title ?? "",
|
||||||
album: s.albumname ?? "",
|
duration: s.interval ?? 0,
|
||||||
duration: s.interval ?? 0,
|
coverUrl: s.album?.mid
|
||||||
coverUrl: s.albummid
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
: "",
|
||||||
: "",
|
platform: "qq",
|
||||||
platform: "qq",
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return { songs, playlists: [], albums: [] };
|
return { songs, playlists: [], albums: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
|
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||||
const res = await this.api.get("/getMusicPlay", {
|
try {
|
||||||
params: { songmid: songId, ...this.cookieParams },
|
const res = await this.api.get("/getMusicPlay", {
|
||||||
});
|
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
});
|
||||||
return playUrl?.url || null;
|
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||||
|
if (playUrl?.url) return playUrl.url;
|
||||||
|
} catch {
|
||||||
|
// try with songid
|
||||||
|
try {
|
||||||
|
const res = await this.api.get("/getMusicPlay", {
|
||||||
|
params: { songid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||||
|
if (playUrl?.url) return playUrl.url;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSongDetail(songId: string): Promise<Song | null> {
|
async getSongDetail(songId: string): Promise<Song | null> {
|
||||||
@@ -115,12 +148,14 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const cdlist = res.data?.response?.cdlist ?? [];
|
const cdlist = res.data?.response?.cdlist ?? [];
|
||||||
if (cdlist.length === 0) return [];
|
if (cdlist.length === 0) return [];
|
||||||
return (cdlist[0].songlist ?? []).map((s: any) => ({
|
return (cdlist[0].songlist ?? []).map((s: any) => ({
|
||||||
id: String(s.songmid ?? s.songid),
|
id: String(s.mid ?? s.songmid ?? s.songid),
|
||||||
name: s.songname ?? s.name ?? "",
|
name: s.songname ?? s.name ?? "",
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||||
album: s.albumname ?? "",
|
album: s.albumname ?? "",
|
||||||
duration: s.interval ?? 0,
|
duration: s.interval ?? 0,
|
||||||
coverUrl: s.albummid
|
coverUrl: s.album?.mid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||||
|
: s.albummid
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||||
: "",
|
: "",
|
||||||
platform: "qq",
|
platform: "qq",
|
||||||
@@ -255,4 +290,26 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return { loggedIn: false };
|
return { loggedIn: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserPlaylists(): Promise<Playlist[]> {
|
||||||
|
if (!this.cookie) return [];
|
||||||
|
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
||||||
|
const uin = uinMatch ? uinMatch[1] : "";
|
||||||
|
if (!uin) return [];
|
||||||
|
try {
|
||||||
|
const res = await this.api.get("/user/getUserPlaylists", {
|
||||||
|
params: { uin, ...this.cookieParams },
|
||||||
|
});
|
||||||
|
if (res.data?.response?.code !== 0) return [];
|
||||||
|
return (res.data?.response?.data?.playlists ?? []).map((p: any) => ({
|
||||||
|
id: String(p.dissid ?? p.id ?? ""),
|
||||||
|
name: p.dissname ?? p.name ?? "",
|
||||||
|
coverUrl: p.imgurl ?? p.coverUrl ?? "",
|
||||||
|
songCount: p.song_count ?? p.listennum ?? 0,
|
||||||
|
platform: "qq",
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import {
|
import {
|
||||||
Client as TS3FullClient,
|
Client as TS3FullClient,
|
||||||
generateIdentity as genTS3Identity,
|
generateIdentity as genTS3Identity,
|
||||||
@@ -7,9 +8,11 @@ import {
|
|||||||
listChannels,
|
listChannels,
|
||||||
listClients,
|
listClients,
|
||||||
clientMove,
|
clientMove,
|
||||||
|
fileTransferDeleteFile,
|
||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
|
type FileUploadInfo,
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
|
|||||||
|
|
||||||
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
||||||
export type { ServerProtocol } from "./protocol-detect.js";
|
export type { ServerProtocol } from "./protocol-detect.js";
|
||||||
|
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
|
||||||
|
|
||||||
|
/** Escape a string for use in TS3 ServerQuery-style commands. */
|
||||||
|
export function escapeTS3(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/ /g, "\\s")
|
||||||
|
.replace(/\//g, "\\/")
|
||||||
|
.replace(/\|/g, "\\p")
|
||||||
|
.replace(/\t/g, "\\t")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\r/g, "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
export interface TS3ClientOptions {
|
export interface TS3ClientOptions {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -172,6 +188,9 @@ export class TS3Client extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
|
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
|
||||||
|
// Forward server password to the protocol library so it can be
|
||||||
|
// included in clientinit for password-protected servers
|
||||||
|
serverPassword: this.options.serverPassword,
|
||||||
logger: {
|
logger: {
|
||||||
debug: (msg) => this.logger.debug(msg),
|
debug: (msg) => this.logger.debug(msg),
|
||||||
info: (msg) => this.logger.info(msg),
|
info: (msg) => this.logger.info(msg),
|
||||||
@@ -278,6 +297,56 @@ export class TS3Client extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Raw command & file transfer pass-through ---
|
||||||
|
|
||||||
|
async execCommand(cmd: string): Promise<void> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
await this.client.execCommand(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire a command without waiting for the server's response. */
|
||||||
|
async sendCommandNoWait(cmd: string): Promise<void> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
await this.client.sendCommandNoWait(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
return this.client.execCommandWithResponse(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileTransferInitUpload(
|
||||||
|
channelID: bigint,
|
||||||
|
path: string,
|
||||||
|
password: string,
|
||||||
|
size: bigint,
|
||||||
|
overwrite = true,
|
||||||
|
): Promise<FileUploadInfo> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
await this.client.uploadFileData(host, info, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
|
||||||
|
if (!this.client) throw new Error("Not connected");
|
||||||
|
await fileTransferDeleteFile(this.client, channelID, paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server host (needed for file transfer TCP connections). */
|
||||||
|
getHost(): string {
|
||||||
|
return this.options.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current channel ID of this client. */
|
||||||
|
getChannelId(): bigint {
|
||||||
|
if (!this.client) return 0n;
|
||||||
|
return this.client.channelID();
|
||||||
|
}
|
||||||
|
|
||||||
private voiceFramesSent = 0;
|
private voiceFramesSent = 0;
|
||||||
|
|
||||||
sendVoiceData(opusFrame: Buffer): void {
|
sendVoiceData(opusFrame: Buffer): void {
|
||||||
|
|||||||
@@ -14,6 +14,38 @@ export interface HttpQueryResult {
|
|||||||
body: unknown;
|
body: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the TS6 HTTP Query returns a non-2xx status.
|
||||||
|
*
|
||||||
|
* The previous implementation silently ignored the status code, so a 400
|
||||||
|
* (bad parameter) or 403 (insufficient permission) looked identical to
|
||||||
|
* success in logs. Callers that rely on the response being applied —
|
||||||
|
* nickname / description / away-status updates — should catch this and
|
||||||
|
* surface it rather than log "updated" for a request that was rejected.
|
||||||
|
*/
|
||||||
|
export class HttpQueryError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: unknown;
|
||||||
|
readonly path: string;
|
||||||
|
|
||||||
|
constructor(path: string, status: number, body: unknown) {
|
||||||
|
const bodySnippet = (() => {
|
||||||
|
if (body == null) return "";
|
||||||
|
const s = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
|
return s.length > 200 ? s.slice(0, 200) + "\u2026" : s;
|
||||||
|
})();
|
||||||
|
super(
|
||||||
|
`TS6 HTTP Query ${path} failed: status=${status}${
|
||||||
|
bodySnippet ? ` body=${bodySnippet}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
this.name = "HttpQueryError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TS6 HTTP Query client.
|
* TS6 HTTP Query client.
|
||||||
*
|
*
|
||||||
@@ -157,12 +189,24 @@ export class TS6HttpQuery {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update client properties (e.g., description) */
|
/**
|
||||||
|
* Update client properties (e.g., description, nickname, away).
|
||||||
|
*
|
||||||
|
* Throws HttpQueryError on non-2xx responses. The TS6 server returns
|
||||||
|
* 400 for invalid parameters and 403 for insufficient permissions;
|
||||||
|
* prior to this check the errors were silently dropped and callers
|
||||||
|
* logged a false "updated" success.
|
||||||
|
*/
|
||||||
async clientUpdate(
|
async clientUpdate(
|
||||||
properties: Record<string, string | number>,
|
properties: Record<string, string | number>,
|
||||||
sid = 1,
|
sid = 1,
|
||||||
): Promise<HttpQueryResult> {
|
): Promise<HttpQueryResult> {
|
||||||
return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
|
const path = `/1/clientupdate?sid=${sid}`;
|
||||||
|
const result = await this.request("POST", path, properties);
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
throw new HttpQueryError(path, result.status, result.body);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move a client to a channel */
|
/** Move a client to a channel */
|
||||||
|
|||||||
Regular → Executable
+23
@@ -1,11 +1,13 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotConfig } from "../../data/config.js";
|
import type { BotConfig } from "../../data/config.js";
|
||||||
|
import { saveConfig } from "../../data/config.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
export function createBotRouter(
|
export function createBotRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
|
configPath: string,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -116,5 +118,26 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||||
|
router.get("/settings", (_req, res) => {
|
||||||
|
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||||
|
router.post("/settings", (req, res) => {
|
||||||
|
const { idleTimeoutMinutes } = req.body;
|
||||||
|
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
||||||
|
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
// 通知所有 bot 实例更新定时器
|
||||||
|
for (const bot of botManager.getAllBots()) {
|
||||||
|
bot.updateIdleTimeout(idleTimeoutMinutes);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
+55
-15
@@ -277,35 +277,54 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play a single song by ID — resolves URL on demand
|
// Play a single song by ID — resolves URL on demand
|
||||||
router.post("/:botId/play-by-id", async (req, res) => {
|
router.post("/:botId/play-song", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { song } = req.body;
|
||||||
const provider = bot.getProviderFor(
|
if (!song || !song.id || !song.platform) {
|
||||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
res.status(400).json({ error: "song object with id and platform is required" });
|
||||||
? platform
|
|
||||||
: "netease"
|
|
||||||
);
|
|
||||||
|
|
||||||
const song = await provider.getSongDetail(songId);
|
|
||||||
if (!song) {
|
|
||||||
res.json({ message: "Song not found" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = bot.getQueueManager();
|
const queue = bot.getQueueManager();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
queue.add({ ...song, platform: provider.platform });
|
queue.add(song);
|
||||||
queue.play();
|
queue.play();
|
||||||
|
|
||||||
bot.getPlayer().resetFailures();
|
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 || song.id}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
|
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:botId/add-song", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { song } = req.body;
|
||||||
|
if (!song || !song.id || !song.platform) {
|
||||||
|
res.status(400).json({ error: "song object with id and platform is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = bot.getQueueManager();
|
||||||
|
const wasIdle = bot.getPlayer().getState() === "idle";
|
||||||
|
queue.add(song);
|
||||||
|
|
||||||
|
// If nothing was playing, start this newly-added song immediately.
|
||||||
|
if (wasIdle) {
|
||||||
|
queue.playAt(queue.size() - 1);
|
||||||
|
bot.getPlayer().resetFailures();
|
||||||
|
await bot.resolveAndPlay(queue.current()!);
|
||||||
|
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -343,6 +362,27 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Profile config endpoints ---
|
||||||
|
|
||||||
|
router.get("/:botId/profile", (req, res) => {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
res.json(bot.getProfileManager().getConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:botId/profile", (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const pm = bot.getProfileManager();
|
||||||
|
pm.updateConfig(req.body);
|
||||||
|
if (database) {
|
||||||
|
database.saveProfileConfig(bot.id, pm.getConfig());
|
||||||
|
}
|
||||||
|
res.json(pm.getConfig());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/:botId/history", (req, res) => {
|
router.get("/:botId/history", (req, res) => {
|
||||||
if (!database) {
|
if (!database) {
|
||||||
res.json({ history: [] });
|
res.json({ history: [] });
|
||||||
|
|||||||
Regular → Executable
+12
-1
@@ -22,6 +22,7 @@ export interface WebServerOptions {
|
|||||||
bilibiliProvider: MusicProvider;
|
bilibiliProvider: MusicProvider;
|
||||||
database: BotDatabase;
|
database: BotDatabase;
|
||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
|
configPath: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
cookieStore?: CookieStore;
|
cookieStore?: CookieStore;
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
@@ -37,11 +38,21 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const logger = options.logger.child({ component: "web" });
|
const logger = options.logger.child({ component: "web" });
|
||||||
|
|
||||||
|
if (options.config.trustProxy) {
|
||||||
|
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
}
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/api/config/public-url", (_req, res) => {
|
||||||
|
const raw = (options.config.publicUrl ?? "").trim();
|
||||||
|
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||||
|
});
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"/api/bot",
|
"/api/bot",
|
||||||
createBotRouter(options.botManager, options.config, logger)
|
createBotRouter(options.botManager, options.config, options.configPath, logger)
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
"/api/music",
|
"/api/music",
|
||||||
|
|||||||
Generated
-57
@@ -797,9 +797,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -821,9 +818,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -845,9 +839,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -869,9 +860,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -893,9 +881,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -917,9 +902,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1088,9 +1070,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1105,9 +1084,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1122,9 +1098,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1139,9 +1112,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1156,9 +1126,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1173,9 +1140,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1190,9 +1154,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1207,9 +1168,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1224,9 +1182,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1241,9 +1196,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1258,9 +1210,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1275,9 +1224,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1292,9 +1238,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -59,10 +59,30 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
|
||||||
|
<div class="link-dialog" @click.stop>
|
||||||
|
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
|
||||||
|
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
|
||||||
|
<input
|
||||||
|
ref="linkInputRef"
|
||||||
|
class="link-dialog-input"
|
||||||
|
:value="linkDialog.url"
|
||||||
|
readonly
|
||||||
|
@focus="($event.target as HTMLInputElement).select()"
|
||||||
|
/>
|
||||||
|
<div class="link-dialog-actions">
|
||||||
|
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
|
||||||
|
{{ linkDialog.copied ? '已复制' : '复制链接' }}
|
||||||
|
</button>
|
||||||
|
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
@@ -71,17 +91,92 @@ const activeBot = computed(() => store.activeBot);
|
|||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
const togglingBots = ref<Record<string, boolean>>({});
|
||||||
|
const linkInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const publicBaseUrl = ref<string | null>(null);
|
||||||
|
|
||||||
|
const linkDialog = reactive({
|
||||||
|
open: false,
|
||||||
|
url: '',
|
||||||
|
name: '',
|
||||||
|
copied: false,
|
||||||
|
});
|
||||||
|
|
||||||
function selectBot(id: string) {
|
function selectBot(id: string) {
|
||||||
store.setActiveBotId(id);
|
store.setActiveBotId(id);
|
||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyBotLink(id: string) {
|
function resolveBaseUrl(): string {
|
||||||
const url = `${window.location.origin}/bot/${id}`;
|
const base = publicBaseUrl.value;
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||||
dropdownOpen.value = false;
|
return window.location.origin;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function tryClipboardWrite(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
ta.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyBotLink(id: string) {
|
||||||
|
const bot = store.bots.find((b) => b.id === id);
|
||||||
|
const url = `${resolveBaseUrl()}/bot/${id}`;
|
||||||
|
linkDialog.url = url;
|
||||||
|
linkDialog.name = bot?.name ?? '机器人';
|
||||||
|
linkDialog.copied = false;
|
||||||
|
linkDialog.open = true;
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
// Try to copy silently; user can still manually select if it fails.
|
||||||
|
const ok = await tryClipboardWrite(url);
|
||||||
|
if (ok) linkDialog.copied = true;
|
||||||
|
await nextTick();
|
||||||
|
linkInputRef.value?.focus();
|
||||||
|
linkInputRef.value?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLinkFromDialog() {
|
||||||
|
const ok = await tryClipboardWrite(linkDialog.url);
|
||||||
|
if (ok) {
|
||||||
|
linkDialog.copied = true;
|
||||||
|
} else {
|
||||||
|
linkInputRef.value?.focus();
|
||||||
|
linkInputRef.value?.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkDialog() {
|
||||||
|
linkDialog.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicBaseUrl() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config/public-url');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { publicUrl?: string | null };
|
||||||
|
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
|
||||||
|
} catch {
|
||||||
|
// ignore — fall back to window.location.origin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
|
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
|
||||||
@@ -108,6 +203,7 @@ function onClickOutside(e: MouseEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onClickOutside);
|
document.addEventListener('click', onClickOutside);
|
||||||
|
loadPublicBaseUrl();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -376,4 +472,87 @@ onUnmounted(() => {
|
|||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: inherit;
|
||||||
|
user-select: all;
|
||||||
|
-webkit-user-select: all;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-dialog-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,6 +3,14 @@
|
|||||||
<div class="queue-header">
|
<div class="queue-header">
|
||||||
<h3 class="queue-title">播放队列</h3>
|
<h3 class="queue-title">播放队列</h3>
|
||||||
<span class="queue-count">{{ botQueue.length }} 首</span>
|
<span class="queue-count">{{ botQueue.length }} 首</span>
|
||||||
|
<button
|
||||||
|
v-if="botQueue.length > 0"
|
||||||
|
class="clear-btn"
|
||||||
|
@click="clearAndStop"
|
||||||
|
title="清空队列并停止播放"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:stop-circle-outline" />
|
||||||
|
</button>
|
||||||
<button class="close-btn" @click="$emit('close')">
|
<button class="close-btn" @click="$emit('close')">
|
||||||
<Icon icon="mdi:close" />
|
<Icon icon="mdi:close" />
|
||||||
</button>
|
</button>
|
||||||
@@ -70,6 +78,15 @@ async function removeSong(index: number) {
|
|||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearAndStop() {
|
||||||
|
try {
|
||||||
|
await store.stop();
|
||||||
|
await store.fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -117,6 +134,14 @@ async function removeSong(index: number) {
|
|||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
color: var(--text-primary);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.queue-empty {
|
.queue-empty {
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -28,9 +28,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import CoverArt from './CoverArt.vue';
|
import CoverArt from './CoverArt.vue';
|
||||||
|
import { Song } from '../stores/player.js';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
|
song: Song;
|
||||||
index: number;
|
index: number;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Song {
|
|||||||
album: string;
|
album: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
platform: 'netease' | 'qq';
|
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotStatus {
|
export interface BotStatus {
|
||||||
@@ -259,6 +259,13 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async playSong(song: Song) {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
|
||||||
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
|
this._syncAfterAction();
|
||||||
|
},
|
||||||
|
|
||||||
async addToQueue(query: string, platform = 'netease') {
|
async addToQueue(query: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
|
await axios.post(`/api/player/${this.activeBotId}/add`, { query, platform });
|
||||||
@@ -269,6 +276,11 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addSong(song: Song) {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
||||||
|
},
|
||||||
|
|
||||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
:active="store.currentSong?.id === song.id"
|
:active="store.currentSong?.id === song.id"
|
||||||
@play="store.play(song.name, song.platform)"
|
@play="store.playSong(song)"
|
||||||
@add="store.addToQueue(song.name, song.platform)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,9 @@ import SongCard from '../components/SongCard.vue';
|
|||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
|
||||||
const history = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
import { Song } from '../stores/player.js';
|
||||||
|
|
||||||
|
const history = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
v-for="song in store.dailySongs.slice(0, 12)"
|
v-for="song in store.dailySongs.slice(0, 12)"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
class="daily-card hover-scale"
|
class="daily-card hover-scale"
|
||||||
@click="store.playById(song.id, song.platform)"
|
@click="store.playSong(song)"
|
||||||
>
|
>
|
||||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
<div class="daily-name">{{ song.name }}</div>
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
v-for="song in store.bilibiliPopular.slice(0, 12)"
|
v-for="song in store.bilibiliPopular.slice(0, 12)"
|
||||||
:key="song.id"
|
:key="song.id"
|
||||||
class="daily-card hover-scale"
|
class="daily-card hover-scale"
|
||||||
@click="store.playById(song.id, song.platform)"
|
@click="store.playSong(song)"
|
||||||
>
|
>
|
||||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||||
<div class="daily-name">{{ song.name }}</div>
|
<div class="daily-name">{{ song.name }}</div>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
:active="store.currentSong?.id === song.id"
|
:active="store.currentSong?.id === song.id"
|
||||||
@play="store.playById(song.id, song.platform)"
|
@play="store.playSong(song)"
|
||||||
@add="store.addToQueueById(song.id, song.platform)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,6 +53,8 @@ import SongCard from '../components/SongCard.vue';
|
|||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
import { Song } from '../stores/player.js';
|
||||||
|
|
||||||
interface PlaylistDetail {
|
interface PlaylistDetail {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -61,18 +63,8 @@ interface PlaylistDetail {
|
|||||||
songCount: number;
|
songCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artist: string;
|
|
||||||
album: string;
|
|
||||||
duration: number;
|
|
||||||
coverUrl: string;
|
|
||||||
platform: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlist = ref<PlaylistDetail | null>(null);
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
const songs = ref<SongItem[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
||||||
async function playAll() {
|
async function playAll() {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
:active="store.currentSong?.id === song.id"
|
:active="store.currentSong?.id === song.id"
|
||||||
@play="store.playById(song.id, song.platform)"
|
@play="store.playSong(song)"
|
||||||
@add="store.addToQueueById(song.id, song.platform)"
|
@add="store.addSong(song)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +50,9 @@ const store = usePlayerStore();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const query = ref((route.query.q as string) || '');
|
const query = ref((route.query.q as string) || '');
|
||||||
const results = ref<Array<{ id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string }>>([]);
|
import { Song } from '../stores/player.js';
|
||||||
|
|
||||||
|
const results = ref<Song[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const searched = ref(false);
|
const searched = ref(false);
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+43
@@ -371,6 +371,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Idle Timeout -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">行为设置</h2>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
|
||||||
|
<div>
|
||||||
|
<div>闲置自动退出</div>
|
||||||
|
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prefix-input-wrap">
|
||||||
|
<input
|
||||||
|
v-model.number="idleTimeout"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input input-sm"
|
||||||
|
style="max-width:80px"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span style="font-size:13px; opacity:0.7">分钟</span>
|
||||||
|
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -661,10 +687,27 @@ async function savePrefix() {
|
|||||||
// Prefix is saved client-side for now
|
// Prefix is saved client-side for now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle timeout
|
||||||
|
const idleTimeout = ref(0);
|
||||||
|
|
||||||
|
async function loadIdleTimeout() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/bot/settings');
|
||||||
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIdleTimeout() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.fetchBots(); // Refresh bot status on page visit
|
store.fetchBots(); // Refresh bot status on page visit
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadQuality();
|
loadQuality();
|
||||||
|
loadIdleTimeout();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user