mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2982948a7 | ||
|
|
b7e1f9f30b | ||
|
|
7fc5186c24 | ||
|
|
de92c8bcd4 |
No files matched your search
+54
-2
@@ -136,6 +136,9 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
private downloader: ChildProcess | null = null;
|
private downloader: ChildProcess | null = null;
|
||||||
private currentTempDir: string | null = null;
|
private currentTempDir: string | null = null;
|
||||||
|
private emptyFrameAttempts = 0;
|
||||||
|
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
||||||
|
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -143,7 +146,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
play(url: string, seekSeconds = 0): void {
|
play(url: string, seekSeconds = 0, songDuration = 0): void {
|
||||||
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
@@ -154,6 +157,8 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.healthyFrames = 0;
|
this.healthyFrames = 0;
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
this.spawnFailed = false;
|
||||||
|
this.emptyFrameAttempts = 0;
|
||||||
|
this.currentSongDuration = songDuration;
|
||||||
|
|
||||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||||
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||||
@@ -420,6 +425,49 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.state === "playing") this.sendNextFrame();
|
if (this.state === "playing") this.sendNextFrame();
|
||||||
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||||
|
|
||||||
|
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
|
||||||
|
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
|
||||||
|
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
|
||||||
|
const elapsed = this.getElapsed();
|
||||||
|
const isNearEnd = this.currentSongDuration > 0
|
||||||
|
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
|
||||||
|
: true; // 未知时长时保守处理
|
||||||
|
|
||||||
|
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
|
this.emptyFrameAttempts++;
|
||||||
|
|
||||||
|
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
|
||||||
|
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
|
||||||
|
this.logger.info({
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
emptyAttempts: this.emptyFrameAttempts,
|
||||||
|
bufferSize: this.pcmBuffer.length,
|
||||||
|
elapsed: Math.round(elapsed),
|
||||||
|
duration: this.currentSongDuration,
|
||||||
|
remaining: Math.round(this.currentSongDuration - elapsed)
|
||||||
|
}, "FFmpeg stopped outputting data near end, ending track");
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
if (this.state !== "idle") {
|
||||||
|
this.state = "idle";
|
||||||
|
// 清理FFmpeg进程
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
const procToKill = this.ffmpeg;
|
||||||
|
const pidToKill = procToKill.pid;
|
||||||
|
this.ffmpeg = null;
|
||||||
|
if (pidToKill) {
|
||||||
|
this.forceCleanup(procToKill, pidToKill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.emit("trackEnd");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 成功获取数据或FFmpeg已结束,重置计数器
|
||||||
|
this.emptyFrameAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
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") {
|
||||||
@@ -472,7 +520,11 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
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); }
|
seek(seconds: number): void {
|
||||||
|
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
|
||||||
|
this.play(this.currentUrl, seconds, this.currentSongDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
||||||
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
||||||
resetFailures(): void { this.consecutiveFailures = 0; }
|
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||||
|
|||||||
+1
-1
@@ -380,7 +380,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
song.url = url;
|
song.url = url;
|
||||||
this.player.play(url);
|
this.player.play(url, 0, song.duration);
|
||||||
this.database.addPlayHistory({
|
this.database.addPlayHistory({
|
||||||
botId: this.id,
|
botId: this.id,
|
||||||
songId: song.id,
|
songId: song.id,
|
||||||
|
|||||||
+65
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import axios, { type AxiosInstance } from "axios";
|
import axios, { type AxiosInstance } from "axios";
|
||||||
import type {
|
import type {
|
||||||
MusicProvider,
|
MusicProvider,
|
||||||
@@ -15,6 +16,16 @@ const BILIBILI_HEADERS = {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key.
|
||||||
|
const WBI_MIXIN_KEY_ENC_TAB = [
|
||||||
|
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
|
||||||
|
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
|
||||||
|
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
|
||||||
|
20, 34, 44, 52,
|
||||||
|
];
|
||||||
|
|
||||||
|
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
|
||||||
|
|
||||||
export class BiliBiliProvider implements MusicProvider {
|
export class BiliBiliProvider implements MusicProvider {
|
||||||
readonly platform = "bilibili" as const;
|
readonly platform = "bilibili" as const;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -24,6 +35,8 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
private cidCache = new Map<string, number>();
|
private cidCache = new Map<string, number>();
|
||||||
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
|
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
|
||||||
private buvidInitialized = false;
|
private buvidInitialized = false;
|
||||||
|
private wbiMixinKey = "";
|
||||||
|
private wbiKeyFetchedAt = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
@@ -62,6 +75,50 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
return combined ? { Cookie: combined } : {};
|
return combined ? { Cookie: combined } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the
|
||||||
|
* mixin key used to sign search params. Required since B站 moved the
|
||||||
|
* search endpoint behind wbi signing — unsigned /search/type now
|
||||||
|
* returns an anti-bot HTML page.
|
||||||
|
*/
|
||||||
|
private async ensureWbiKeys(): Promise<void> {
|
||||||
|
if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await this.api.get("/x/web-interface/nav", {
|
||||||
|
headers: this.cookieHeaders,
|
||||||
|
validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img
|
||||||
|
});
|
||||||
|
const wbi = res.data?.data?.wbi_img;
|
||||||
|
const imgUrl: string = wbi?.img_url ?? "";
|
||||||
|
const subUrl: string = wbi?.sub_url ?? "";
|
||||||
|
const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? "";
|
||||||
|
const subKey = subUrl.split("/").pop()?.split(".")[0] ?? "";
|
||||||
|
if (!imgKey || !subKey) {
|
||||||
|
throw new Error("Bilibili wbi keys unavailable");
|
||||||
|
}
|
||||||
|
const raw = imgKey + subKey;
|
||||||
|
this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "")
|
||||||
|
.join("")
|
||||||
|
.slice(0, 32);
|
||||||
|
this.wbiKeyFetchedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */
|
||||||
|
private signWbi(params: Record<string, string | number>): Record<string, string> {
|
||||||
|
const withTs: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) withTs[k] = String(v);
|
||||||
|
withTs.wts = String(Math.floor(Date.now() / 1000));
|
||||||
|
const sorted = Object.keys(withTs)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`)
|
||||||
|
.join("&");
|
||||||
|
withTs.w_rid = createHash("md5")
|
||||||
|
.update(sorted + this.wbiMixinKey)
|
||||||
|
.digest("hex");
|
||||||
|
return withTs;
|
||||||
|
}
|
||||||
|
|
||||||
setQuality(quality: string): void {
|
setQuality(quality: string): void {
|
||||||
this.quality = quality;
|
this.quality = quality;
|
||||||
}
|
}
|
||||||
@@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
await this.ensureBuvidCookie();
|
await this.ensureBuvidCookie();
|
||||||
const res = await this.api.get("/x/web-interface/search/type", {
|
await this.ensureWbiKeys();
|
||||||
params: {
|
const signed = this.signWbi({
|
||||||
search_type: "video",
|
search_type: "video",
|
||||||
keyword: query,
|
keyword: query,
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
},
|
});
|
||||||
|
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||||
|
params: signed,
|
||||||
headers: this.cookieHeaders,
|
headers: this.cookieHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user