mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge branch 'main' of https://github.com/ZHANGTIANYAO1/teamspeak-music-bot into dev
This commit is contained in:
commit
1a663156c3
8 files changed
+778
-412
No files matched your search
@@ -41,7 +41,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" />
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -65,8 +66,8 @@ FFmpeg **已自动内置**,无需手动安装。
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 下载项目
|
# 下载项目
|
||||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||||
cd tsmusicbot
|
cd teamspeak-music-bot
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
npm install
|
npm install
|
||||||
@@ -86,8 +87,8 @@ npm start
|
|||||||
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
所有依赖已内置(Node.js、FFmpeg、Opus 编码器),无需安装任何额外软件。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ZHANGTIANYAO1/tsmusicbot.git
|
git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
||||||
cd tsmusicbot/scripts/docker
|
cd teamspeak-music-bot/scripts/docker
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ sudo systemctl start tsmusicbot
|
|||||||
## 项目架构
|
## 项目架构
|
||||||
|
|
||||||
```
|
```
|
||||||
tsmusicbot/
|
teamspeak-music-bot/
|
||||||
├── src/ # 后端源码 (TypeScript)
|
├── src/ # 后端源码 (TypeScript)
|
||||||
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
│ ├── audio/ # 音频管线:FFmpeg → PCM → Opus → 20ms 帧
|
||||||
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
│ │ ├── encoder.ts # Opus 编码器 (@discordjs/opus)
|
||||||
|
|||||||
Generated
+618
-371
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@honeybbq/teamspeak-client": "^0.2.1",
|
"@honeybbq/teamspeak-client": "^0.2.1",
|
||||||
"@koa/router": "^15.4.0",
|
"@koa/router": "^15.4.0",
|
||||||
"@sansenjian/qq-music-api": "file:../qq-music-api",
|
"@sansenjian/qq-music-api": "^2.2.10",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
|||||||
+10
-13
@@ -25,30 +25,27 @@ RUN cd web && npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Install production dependencies (with native addons compiled) in the builder
|
||||||
|
# so we don't need build tools in the production image.
|
||||||
|
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
||||||
|
|
||||||
# --- Stage 2: Production image ---
|
# --- Stage 2: Production image ---
|
||||||
FROM node:20-slim
|
FROM node:20-slim
|
||||||
|
|
||||||
# Install only runtime native build tools (needed for native module rebuild)
|
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
||||||
|
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
||||||
|
# System-installed FFmpeg is always compatible with the container runtime.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 make g++ && \
|
ffmpeg && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy built output
|
# Copy built output and pre-compiled production node_modules from builder
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/web/dist ./web/dist
|
COPY --from=builder /app/web/dist ./web/dist
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
# Install production dependencies (includes ffmpeg-static, opus, sqlite3)
|
|
||||||
# These need to compile native addons inside the container
|
|
||||||
RUN npm ci --production && npm cache clean --force
|
|
||||||
|
|
||||||
# Remove build tools to reduce image size
|
|
||||||
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# FFmpeg is bundled via ffmpeg-static — no system ffmpeg needed
|
|
||||||
|
|
||||||
# Data directory for database, cookies, logs
|
# Data directory for database, cookies, logs
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# TSMusicBot — Docker Compose
|
# TSMusicBot — Docker Compose
|
||||||
# 一键部署:docker-compose up -d
|
# 一键部署:docker-compose up -d
|
||||||
#
|
#
|
||||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
|
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
|
||||||
# 无需安装任何额外软件
|
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tsmusicbot:
|
tsmusicbot:
|
||||||
|
|||||||
+18
-7
@@ -35,28 +35,39 @@ function ffmpegWorks(bin: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to bundled ffmpeg-static binary
|
||||||
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
|
// 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.
|
// 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";
|
||||||
|
|
||||||
// Only try bundled binary if platform matches
|
|
||||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
if (ffmpegPath && (onWindows === isWinPath)) {
|
||||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
||||||
return ffmpegPath;
|
return ffmpegPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall back to system ffmpeg
|
|
||||||
if (ffmpegWorks("ffmpeg")) {
|
|
||||||
return "ffmpeg";
|
|
||||||
}
|
|
||||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
||||||
return "ffmpeg";
|
return "ffmpeg";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
|
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
||||||
function getFfmpegCommand(): string {
|
function getFfmpegCommand(): string {
|
||||||
return resolvedFfmpeg;
|
return resolvedFfmpeg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user