mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
10 files changed
+194
-303
No files matched your search
+103
-199
@@ -5,11 +5,12 @@ import { accessSync, chmodSync, constants } from "node:fs";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.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 ffmpegPath: string | null = require("ffmpeg-static");
|
||||
|
||||
/** Ensure the given binary has execute permission. */
|
||||
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
|
||||
const globalActivePids = new Set<number>();
|
||||
|
||||
function isExecutable(binPath: string): boolean {
|
||||
try {
|
||||
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 {
|
||||
try {
|
||||
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 = (() => {
|
||||
// 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
|
||||
// it may return a Windows .exe path if node_modules was copied cross-platform.
|
||||
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
|
||||
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
|
||||
const onWindows = process.platform === "win32";
|
||||
|
||||
if (ffmpegPath && (onWindows === isWinPath)) {
|
||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
||||
return ffmpegPath;
|
||||
}
|
||||
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
|
||||
}
|
||||
|
||||
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
||||
return "ffmpeg";
|
||||
})();
|
||||
|
||||
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
||||
function getFfmpegCommand(): string {
|
||||
return resolvedFfmpeg;
|
||||
}
|
||||
@@ -93,12 +70,12 @@ export class AudioPlayer extends EventEmitter {
|
||||
private nextFrameTime = 0;
|
||||
private currentUrl = "";
|
||||
private seekOffset = 0;
|
||||
private framesPlayed = 0; // ground truth: number of 20ms frames sent
|
||||
private framesPlayed = 0;
|
||||
private sessionId = 0;
|
||||
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
||||
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
||||
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
|
||||
private static readonly BUFFER_LOW_WATER = 256 * 1024;
|
||||
private ffmpegPaused = false;
|
||||
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
|
||||
private spawnFailed = false;
|
||||
private consecutiveFailures = 0;
|
||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
||||
|
||||
@@ -109,112 +86,125 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
play(url: string, seekSeconds = 0): void {
|
||||
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||
this.stop();
|
||||
this.sessionId++;
|
||||
const playSessionId = this.sessionId;
|
||||
|
||||
const currentSessionId = this.sessionId;
|
||||
this.currentUrl = url;
|
||||
this.seekOffset = seekSeconds;
|
||||
this.framesPlayed = 0;
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
|
||||
// Prevent rapid-fire spawn attempts when ffmpeg is broken
|
||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||
this.logger.error(
|
||||
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
|
||||
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
|
||||
);
|
||||
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||
this.state = "idle";
|
||||
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
|
||||
this.emit("error", new Error("ffmpeg unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
// BiliBili CDN requires Referer header for audio playback
|
||||
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
||||
args.push(
|
||||
"-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("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n");
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-reconnect", "1",
|
||||
"-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",
|
||||
"-",
|
||||
);
|
||||
args.push("-reconnect", "1", "-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();
|
||||
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
// Prevent stream errors from crashing the process
|
||||
this.ffmpeg.stdout!.on("error", (err) => {
|
||||
this.logger.warn({ err }, "FFmpeg stdout error");
|
||||
});
|
||||
this.ffmpeg.stderr!.on("error", (err) => {
|
||||
this.logger.warn({ err }, "FFmpeg stderr error");
|
||||
});
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
globalActivePids.add(currentPid);
|
||||
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
|
||||
}
|
||||
|
||||
let gotFirstData = false;
|
||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (!gotFirstData) {
|
||||
gotFirstData = true;
|
||||
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
|
||||
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
|
||||
if (this.sessionId !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.ffmpeg.stdout.pause();
|
||||
this.ffmpegPaused = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
|
||||
if (this.sessionId === playSessionId) {
|
||||
this.ffmpeg = null; // Signal frame loop that no more data is coming
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
|
||||
// 只有当前会话的进程结束才置空变量
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.ffmpeg = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
this.logger.error({ err }, "FFmpeg error");
|
||||
if (this.sessionId === playSessionId) {
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
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.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;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.frameLoopRunning) return;
|
||||
this.frameLoopRunning = true;
|
||||
@@ -224,50 +214,37 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
private scheduleNextFrame(): void {
|
||||
if (!this.frameLoopRunning) return;
|
||||
|
||||
const loopSessionId = this.sessionId;
|
||||
|
||||
this.nextFrameTime += FRAME_DURATION_MS;
|
||||
const now = performance.now();
|
||||
const delay = Math.max(0, this.nextFrameTime - now);
|
||||
const delay = Math.max(0, this.nextFrameTime - performance.now());
|
||||
|
||||
setTimeout(() => {
|
||||
// Discard callback from a stale play session
|
||||
if (loopSessionId !== this.sessionId) return;
|
||||
if (!this.frameLoopRunning) return;
|
||||
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
|
||||
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
|
||||
|
||||
if (this.state === "playing") {
|
||||
this.sendNextFrame();
|
||||
} else if (this.state === "paused") {
|
||||
this.nextFrameTime = performance.now();
|
||||
}
|
||||
if (this.state === "playing") this.sendNextFrame();
|
||||
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||
|
||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
this.frameLoopRunning = false;
|
||||
if (this.state !== "idle") {
|
||||
this.state = "idle";
|
||||
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
|
||||
if (this.spawnFailed) {
|
||||
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
|
||||
} else {
|
||||
this.consecutiveFailures = 0; // Reset on successful track completion
|
||||
if (!this.spawnFailed) {
|
||||
this.consecutiveFailures = 0;
|
||||
this.emit("trackEnd");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleNextFrame();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private sendNextFrame(): void {
|
||||
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
||||
|
||||
const pcmFrame = this.pcmBuffer.subarray(0, 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) {
|
||||
this.ffmpeg.stdout.resume();
|
||||
this.ffmpegPaused = false;
|
||||
@@ -278,101 +255,28 @@ export class AudioPlayer extends EventEmitter {
|
||||
const opusFrame = this.encoder.encode(adjusted);
|
||||
this.emit("frame", opusFrame);
|
||||
this.framesPlayed++;
|
||||
|
||||
if (this.framesPlayed === 1) {
|
||||
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
|
||||
}
|
||||
// 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) {
|
||||
this.logger.error({ err }, "Error encoding/sending audio frame");
|
||||
this.emit("error", err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyVolume(pcm: Buffer): Buffer {
|
||||
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);
|
||||
for (let i = 0; i < pcm.length; i += 2) {
|
||||
let sample = pcm.readInt16LE(i);
|
||||
sample = Math.round(sample * factor);
|
||||
if (sample > 32767) sample = 32767;
|
||||
else if (sample < -32768) sample = -32768;
|
||||
out.writeInt16LE(sample, i);
|
||||
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Actual elapsed time in seconds (ground truth from frame count) */
|
||||
getElapsed(): number {
|
||||
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
if (!this.currentUrl) return;
|
||||
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
|
||||
// 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;
|
||||
}
|
||||
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
||||
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
|
||||
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; }
|
||||
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
|
||||
getVolume(): number { return this.volume; }
|
||||
getState(): PlayerState { return this.state; }
|
||||
}
|
||||
+25
-9
@@ -42,7 +42,7 @@ export class QQMusicProvider implements MusicProvider {
|
||||
|
||||
const songs: Song[] = (res.data?.response?.data?.song?.list ?? []).map(
|
||||
(s: any) => ({
|
||||
id: String(s.songmid ?? s.songid),
|
||||
id: String(s.mid ?? s.songmid ?? s.songid),
|
||||
name: s.songname ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.albumname ?? "",
|
||||
@@ -57,12 +57,26 @@ export class QQMusicProvider implements MusicProvider {
|
||||
return { songs, playlists: [], albums: [] };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string, _quality?: string): Promise<string | null> {
|
||||
const res = await this.api.get("/getMusicPlay", {
|
||||
params: { songmid: songId, ...this.cookieParams },
|
||||
});
|
||||
const playUrl = res.data?.data?.playUrl?.[songId];
|
||||
return playUrl?.url || null;
|
||||
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await this.api.get("/getMusicPlay", {
|
||||
params: { songmid: songId, quality: quality ?? this.quality, ...this.cookieParams },
|
||||
});
|
||||
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> {
|
||||
@@ -115,12 +129,14 @@ export class QQMusicProvider implements MusicProvider {
|
||||
const cdlist = res.data?.response?.cdlist ?? [];
|
||||
if (cdlist.length === 0) return [];
|
||||
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 ?? "",
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
||||
album: s.albumname ?? "",
|
||||
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`
|
||||
: "",
|
||||
platform: "qq",
|
||||
|
||||
+34
-15
@@ -277,35 +277,54 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
const provider = bot.getProviderFor(
|
||||
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||
? platform
|
||||
: "netease"
|
||||
);
|
||||
|
||||
const song = await provider.getSongDetail(songId);
|
||||
if (!song) {
|
||||
res.json({ message: "Song not found" });
|
||||
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();
|
||||
queue.clear();
|
||||
queue.add({ ...song, platform: provider.platform });
|
||||
queue.add(song);
|
||||
queue.play();
|
||||
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
if (!ok) {
|
||||
res.json({ message: `Cannot play: ${song.name}` });
|
||||
res.json({ message: `Cannot play: ${song.name || song.id}` });
|
||||
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) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
Generated
-57
@@ -797,9 +797,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -821,9 +818,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -845,9 +839,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -869,9 +860,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -893,9 +881,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -917,9 +902,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1088,9 +1070,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1105,9 +1084,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1122,9 +1098,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1139,9 +1112,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1156,9 +1126,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1173,9 +1140,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1190,9 +1154,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1207,9 +1168,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1224,9 +1182,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1241,9 +1196,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1258,9 +1210,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1275,9 +1224,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1292,9 +1238,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import { Song } from '../stores/player.js';
|
||||
|
||||
defineProps<{
|
||||
song: { id: string; name: string; artist: string; album: string; duration: number; coverUrl: string; platform: string };
|
||||
song: Song;
|
||||
index: number;
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Song {
|
||||
album: string;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq';
|
||||
platform: 'netease' | 'qq' | 'bilibili' | 'youtube';
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
@@ -259,6 +259,13 @@ export const usePlayerStore = defineStore('player', {
|
||||
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') {
|
||||
if (!this.activeBotId) return;
|
||||
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 });
|
||||
},
|
||||
|
||||
async addSong(song: Song) {
|
||||
if (!this.activeBotId) return;
|
||||
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
||||
},
|
||||
|
||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.play(song.name, song.platform)"
|
||||
@add="store.addToQueue(song.name, song.platform)"
|
||||
@play="store.playSong(song)"
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,7 +35,9 @@ import SongCard from '../components/SongCard.vue';
|
||||
|
||||
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);
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
v-for="song in store.dailySongs.slice(0, 12)"
|
||||
:key="song.id"
|
||||
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" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
v-for="song in store.bilibiliPopular.slice(0, 12)"
|
||||
:key="song.id"
|
||||
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" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.playById(song.id, song.platform)"
|
||||
@add="store.addToQueueById(song.id, song.platform)"
|
||||
@play="store.playSong(song)"
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -53,6 +53,8 @@ import SongCard from '../components/SongCard.vue';
|
||||
const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
import { Song } from '../stores/player.js';
|
||||
|
||||
interface PlaylistDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -61,18 +63,8 @@ interface PlaylistDetail {
|
||||
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 songs = ref<SongItem[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
async function playAll() {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
:song="song"
|
||||
:index="i + 1"
|
||||
:active="store.currentSong?.id === song.id"
|
||||
@play="store.playById(song.id, song.platform)"
|
||||
@add="store.addToQueueById(song.id, song.platform)"
|
||||
@play="store.playSong(song)"
|
||||
@add="store.addSong(song)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,9 @@ const store = usePlayerStore();
|
||||
const route = useRoute();
|
||||
|
||||
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 searched = ref(false);
|
||||
|
||||
|
||||
Reference in new issue
Block a user