기존: player_play 가 channelJoin() 완료 후에야 search() 를 시작(직렬)하고, 첫 곡은 playNext 시점에 yt-dlp 리졸브(콜드 3.6~4.2s)를 그제서야 수행했다. - LavalinkManager.search 를 resolveQuery(해석) + addResolved(큐 반영)로 분리. resolveQuery 는 플레이어가 필요 없어 음성접속과 동시에 실행 가능. - resolveQuery 가 해석 직후 prewarmTrack() 으로 첫 곡 오디오 리졸브를 즉시 예열. GuildPlayer.resolvePlayable 과 동일한 target 계산식을 써 캐시가 확실히 적중하게 함. - RedisClient player_play/player_playlist: 해석을 먼저 출발시키고 음성접속과 병렬 진행. - 구간 계측 로그 추가(음성접속/해석대기/오디오 리졸브 대기). 검증: tsc 통과. 실측 A/B(동일 조건, 미캐시 곡): 기존 직렬 접속2.00s+리졸브3.03s = 5.03s → 신규 병렬 2.00s+0.97s = 2.98s (약 2s 단축).
644 lines
25 KiB
TypeScript
644 lines
25 KiB
TypeScript
import { Guild, Message, TextChannel } from "discord.js";
|
|
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent, type WebSocketClosedEvent } from "shoukaku";
|
|
import { client, lavalinkManager, Redis } from "../index";
|
|
import { timeFormat } from "../utils/music/Utils";
|
|
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
|
import { GuildType } from "../../db/db";
|
|
import { DB } from "../utils/Database";
|
|
import { shuffle } from "../utils/Shuffle";
|
|
import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
|
import { Logger } from "../utils/Logger";
|
|
import { Config } from "../utils/Config";
|
|
|
|
const DelayAfterErrMs = 1000 * 5;
|
|
const idleEndTime = 1000 * 60 * 10;
|
|
// 재생 위치가 진행되지 않는지 감시하는 주기 / 멈춤으로 판단하는 임계값
|
|
const StallCheckIntervalMs = 1000 * 5;
|
|
const StallThresholdMs = 1000 * 20;
|
|
const MaxLoadFailRetry = 3;
|
|
const LoadFailRetryDelayMs = 2500;
|
|
|
|
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
|
|
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
|
|
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
|
|
|
|
export class GuildPlayer {
|
|
private isDead = false;
|
|
private canRecommend = true;
|
|
private _GDB: GuildType | undefined;
|
|
private lastPlayedTrack: QueueTrack | undefined;
|
|
public queue: QueueTrack[] = [];
|
|
private errorTimer: NodeJS.Timeout | undefined;
|
|
private endTimer: NodeJS.Timeout | undefined;
|
|
private closedTimer: NodeJS.Timeout | undefined;
|
|
private stallWatchdog: NodeJS.Timeout | undefined;
|
|
private lastPosition = 0;
|
|
private lastProgressAt = 0;
|
|
private loadFailCount = 0;
|
|
|
|
constructor(
|
|
public guild: Guild,
|
|
public player: Player,
|
|
public voiceChannelId: string,
|
|
public textChannel: TextChannel,
|
|
public msg: Message,
|
|
) {
|
|
this.player.setGlobalVolume(50);
|
|
this.attachPlayerListeners();
|
|
}
|
|
|
|
private attachPlayerListeners() {
|
|
this.player.on("start", (_data: TrackStartEvent) => {
|
|
// endTimer가 남아있으면 제거 (새 곡 재생 시작)
|
|
if (this.endTimer !== undefined) {
|
|
clearTimeout(this.endTimer);
|
|
this.endTimer = undefined;
|
|
}
|
|
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
|
|
this.startStallWatchdog();
|
|
// 현재 곡이 재생되는 동안 다음 곡을 미리 리졸브(곡 전환 지연 제거)
|
|
this.prefetch(this.queue[1]);
|
|
Redis?.publishState("player_update", {
|
|
guildId: this.guild.id,
|
|
});
|
|
});
|
|
this.player.on("end", async (data: TrackEndEvent) => {
|
|
try {
|
|
if (this.isDead) return;
|
|
if (data.reason === "replaced") return;
|
|
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
|
|
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
|
|
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
|
|
if (data.reason === "loadFailed" && this.queue[0]) {
|
|
this.loadFailCount += 1;
|
|
if (this.loadFailCount <= MaxLoadFailRetry) {
|
|
const cur = this.queue[0];
|
|
Logger.warn(`[GuildPlayer] 재생 실패, 같은 곡 재시도 ${this.loadFailCount}/${MaxLoadFailRetry}: "${cur.info.title}"`);
|
|
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
|
|
if (this.isDead) return;
|
|
if (this.queue[0]?.encoded === cur.encoded) {
|
|
const reEncoded = await this.resolvePlayable(cur);
|
|
await this.player.playTrack({ track: { encoded: reEncoded } });
|
|
return;
|
|
}
|
|
} else {
|
|
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
|
|
}
|
|
}
|
|
this.loadFailCount = 0;
|
|
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
|
this.lastPlayedTrack = this.queue.shift();
|
|
if (this.queue.length > 0) {
|
|
await this.playNext();
|
|
} else if (this.isRecommend && this.canRecommend) {
|
|
await this.autoPlay();
|
|
} else {
|
|
this.end();
|
|
}
|
|
} catch (err) {
|
|
Logger.error(`[GuildPlayer] end 이벤트 처리 중 에러: ${String(err)}`);
|
|
}
|
|
});
|
|
this.player.on("closed", (data: WebSocketClosedEvent) => {
|
|
if (this.isDead) return;
|
|
Logger.warn(
|
|
`[GuildPlayer] 음성 WS 끊김 (code=${data.code}, reason="${data.reason || "(none)"}", byRemote=${data.byRemote}). 5초 후 복구 시도...`,
|
|
);
|
|
// 이전 closed 타이머가 있으면 제거
|
|
if (this.closedTimer !== undefined) {
|
|
clearTimeout(this.closedTimer);
|
|
}
|
|
this.closedTimer = setTimeout(async () => {
|
|
this.closedTimer = undefined;
|
|
if (this.isDead) return;
|
|
|
|
// 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다!
|
|
const meChannelId = this.guild.members.me?.voice?.channelId;
|
|
if (!meChannelId) {
|
|
Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`);
|
|
return this.delete();
|
|
}
|
|
|
|
// 봇이 아직 보이스 채널에 남아있으면 자가 회복 시도
|
|
try {
|
|
await this.reconnect(meChannelId);
|
|
} catch (err) {
|
|
Logger.error(`[GuildPlayer] 음성 재접속 실패: ${String(err)}. player를 초기화합니다.`);
|
|
this.delete();
|
|
}
|
|
}, 5000);
|
|
});
|
|
|
|
this.player.on("exception", async (data) => {
|
|
try {
|
|
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
|
|
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
|
|
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
|
|
} catch (err) {
|
|
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
|
|
}
|
|
});
|
|
this.player.on("stuck", async (data) => {
|
|
try {
|
|
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과. 다음 곡으로 넘어갑니다.`);
|
|
// errMsg는 5초 뒤 같은 곡(queue[0])을 다시 재생해 무한 반복될 수 있으므로,
|
|
// stopTrack으로 end 이벤트를 유도해 실제로 다음 곡으로 넘긴다.
|
|
await this.player.stopTrack();
|
|
} catch (err) {
|
|
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 음성 WS가 끊겼지만 봇이 아직 보이스 채널에 남아있을 때, player를 새로 만들어 현재 곡을 이어서 재생. */
|
|
private async reconnect(targetChannelId: string) {
|
|
const currentTrack = this.nowTrack;
|
|
const currentPosition = this.position;
|
|
const wasPaused = this.isPaused;
|
|
const savedVolume = this.player.volume;
|
|
|
|
Logger.info(
|
|
`[GuildPlayer] 음성 재접속 시도 (channel=${targetChannelId}, track="${currentTrack?.info.title ?? "(none)"}", position=${currentPosition}ms, paused=${wasPaused})`,
|
|
);
|
|
|
|
// 기존 player listener 해제 + 정리
|
|
this.player.removeAllListeners();
|
|
try {
|
|
await this.player.destroy();
|
|
} catch (err) {
|
|
Logger.warn(`[GuildPlayer] 기존 player.destroy 중 에러(무시): ${String(err)}`);
|
|
}
|
|
try {
|
|
await lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
|
} catch (err) {
|
|
Logger.warn(`[GuildPlayer] 기존 leaveVoiceChannel 중 에러(무시): ${String(err)}`);
|
|
}
|
|
|
|
if (this.isDead) return;
|
|
|
|
// 게이트웨이 측 정리 대기
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
if (this.isDead) return;
|
|
|
|
// 재접속 (봇이 옮겨졌을 수도 있으니 me의 현재 채널로)
|
|
const newPlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
|
|
guildId: this.guild.id,
|
|
channelId: targetChannelId,
|
|
shardId: this.guild.shardId,
|
|
deaf: true,
|
|
mute: false,
|
|
});
|
|
this.player = newPlayer;
|
|
this.voiceChannelId = targetChannelId;
|
|
this.attachPlayerListeners();
|
|
await this.player.setGlobalVolume(savedVolume || 50);
|
|
|
|
// 곡이 있었으면 이어서 재생
|
|
if (currentTrack) {
|
|
const reEncoded = await this.resolvePlayable(currentTrack);
|
|
await this.player.playTrack({
|
|
track: { encoded: reEncoded },
|
|
position: currentPosition,
|
|
paused: wasPaused,
|
|
});
|
|
Logger.info(`[GuildPlayer] 음성 재접속 성공. 곡을 이어서 재생합니다.`);
|
|
} else {
|
|
Logger.info(`[GuildPlayer] 음성 재접속 성공 (재생 중인 곡 없음).`);
|
|
}
|
|
}
|
|
|
|
private get GDB() {
|
|
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
|
|
return this._GDB;
|
|
}
|
|
public get isPlaying(): boolean {
|
|
return this.player.track !== null;
|
|
}
|
|
public get nowTrack(): QueueTrack | null {
|
|
return this.queue[0] ?? null;
|
|
}
|
|
public get isPaused(): boolean {
|
|
return this.player.paused;
|
|
}
|
|
public get isRecommend(): boolean {
|
|
return this.GDB?.options.recommend ?? false;
|
|
}
|
|
public get position(): number {
|
|
if (!this.isPlaying) return 0;
|
|
return this.player.position;
|
|
}
|
|
public get volume(): number {
|
|
return this.player.volume;
|
|
}
|
|
|
|
public async addTrack(track: Track, userId: string) {
|
|
this.queue.push({ ...track, userId });
|
|
if (this.queue.length === 1) {
|
|
// 큐가 비어있으면 바로 재생
|
|
await this.playNext();
|
|
} else {
|
|
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
|
|
this.prefetch(this.queue[1]);
|
|
this.setMsg({ player: false, queue: true });
|
|
}
|
|
}
|
|
|
|
public async addTracks(tracks: Track[], userId: string) {
|
|
const first = this.queue.length === 0;
|
|
for (const track of tracks) this.queue.push({ ...track, userId });
|
|
if (first) {
|
|
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
|
|
this.prefetch(this.queue[1]);
|
|
await this.playNext();
|
|
} else {
|
|
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
|
|
this.prefetch(this.queue[1]);
|
|
this.setMsg({ player: false, queue: true });
|
|
}
|
|
}
|
|
|
|
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
|
|
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
|
|
private async resolvePlayable(track: QueueTrack): Promise<string> {
|
|
try {
|
|
const info: any = (track as any).info ?? {};
|
|
const src = info.sourceName;
|
|
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
|
|
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
|
|
if (!target) return track.encoded;
|
|
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
|
|
if (!r.ok) return track.encoded;
|
|
const data = (await r.json()) as { url?: string };
|
|
if (!data.url) return track.encoded;
|
|
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
|
|
if (!node) return track.encoded;
|
|
const res = await node.rest.resolve(data.url);
|
|
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
|
|
return track.encoded;
|
|
} catch (e) {
|
|
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
|
|
return track.encoded;
|
|
}
|
|
}
|
|
|
|
/** 트랙의 재생용 encoded를 미리(백그라운드) 리졸브해 캐시한다. 이미 진행 중이면 무시. */
|
|
private prefetch(track?: QueueTrack) {
|
|
if (!track || track._resolved) return;
|
|
track._resolved = this.resolvePlayable(track).catch((e) => {
|
|
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
|
|
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
|
|
return track.encoded;
|
|
});
|
|
}
|
|
|
|
/** 재생용 encoded를 가져온다. 프리페치되어 있으면 즉시, 아니면 지금 리졸브한다. */
|
|
private getResolved(track: QueueTrack): Promise<string> {
|
|
if (!track._resolved) this.prefetch(track);
|
|
return track._resolved!;
|
|
}
|
|
|
|
public async playNext() {
|
|
const track = this.queue[0];
|
|
if (!track) return this.end();
|
|
const tResolve = Date.now();
|
|
const encoded = await this.getResolved(track);
|
|
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
|
|
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
|
|
this.prefetch(this.queue[1]);
|
|
await this.player.playTrack({ track: { encoded } });
|
|
// 재생관련
|
|
this.setMsg();
|
|
}
|
|
|
|
public async setPause() {
|
|
if (this.isPaused) {
|
|
await this.player.setPaused(false);
|
|
this.setMsg({ player: true, queue: false });
|
|
} else if (this.isPlaying) {
|
|
await this.player.setPaused(true);
|
|
this.setMsg({ player: true, queue: false });
|
|
}
|
|
}
|
|
|
|
public stop() {
|
|
this.queue = [];
|
|
this.canRecommend = false;
|
|
this.player.stopTrack();
|
|
}
|
|
|
|
public skip() {
|
|
// 곡 스킵했을때
|
|
this.player.stopTrack();
|
|
}
|
|
|
|
public setShuffle() {
|
|
let nowQueue = this.queue.slice(1);
|
|
nowQueue = shuffle(nowQueue);
|
|
this.queue = [this.queue[0], ...nowQueue];
|
|
this.setMsg({ player: false, queue: true });
|
|
}
|
|
|
|
public setRecommend() {
|
|
if (!this.GDB) return;
|
|
this.GDB.options.recommend = !this.GDB.options.recommend;
|
|
DB.guild.update(this.GDB);
|
|
this.setMsg({ player: false, queue: false });
|
|
}
|
|
|
|
public setVolume(volume: number) {
|
|
if (!this.isPlaying) return;
|
|
if (!this.nowTrack) return;
|
|
this.player.setGlobalVolume(volume);
|
|
this.setMsg({ player: true, queue: false });
|
|
}
|
|
|
|
public seek(num: number) {
|
|
if (!this.isPlaying) return;
|
|
if (!this.nowTrack) return;
|
|
this.player.seekTo(num);
|
|
Redis?.publishState("player_update", { guildId: this.guild.id });
|
|
}
|
|
|
|
private async autoPlay() {
|
|
if (!this.lastPlayedTrack) return;
|
|
const source = this.lastPlayedTrack.info.sourceName;
|
|
const trackId = this.lastPlayedTrack.info.identifier;
|
|
const title = this.lastPlayedTrack.info.title;
|
|
const author = this.lastPlayedTrack.info.author;
|
|
let result;
|
|
let usedYtFallback = false;
|
|
// 중복 제거 기준 ID. 기본은 lastPlayed의 identifier, 다만 Spotify→YouTube 폴백 시에는
|
|
// RD 믹스의 시드(YouTube videoId)와 비교해야 첫 곡(=방금 끝난 곡의 YouTube 버전)을 제거할 수 있음.
|
|
let dedupId = trackId;
|
|
|
|
if (source === "spotify") {
|
|
const sprecUri = `sprec:seed_tracks=${trackId}&limit=11`;
|
|
try {
|
|
result = await this.player.node.rest.resolve(sprecUri);
|
|
Logger.info(`[autoPlay] sprec loadType=${result?.loadType ?? "(null)"} for "${author} - ${title}"`);
|
|
} catch (err) {
|
|
Logger.warn(`[autoPlay] sprec resolve throw: ${String(err)}`);
|
|
result = undefined;
|
|
}
|
|
// 2024-11-27 이후 신규/development 모드 앱은 Spotify Recommendations API 접근이 막혀서
|
|
// sprec이 EMPTY로 떨어지는 경우가 있음. extended 모드 토큰이면 동작함.
|
|
// 결과 없으면 YouTube로 폴백.
|
|
if (!result
|
|
|| result.loadType === LoadType.EMPTY
|
|
|| result.loadType === LoadType.ERROR
|
|
|| (result.loadType === LoadType.PLAYLIST && result.data.tracks.length === 0)
|
|
) {
|
|
if (result?.loadType === LoadType.ERROR) {
|
|
Logger.warn(`[autoPlay] sprec ERROR: ${result.data?.message ?? "unknown"}. YouTube으로 폴백합니다.`);
|
|
} else {
|
|
Logger.warn(`[autoPlay] sprec 결과 없음. YouTube으로 폴백합니다.`);
|
|
}
|
|
const fallback = await this.youtubeMixFromSpotifyTrack(author, title);
|
|
result = fallback?.result;
|
|
if (fallback?.seedYtId) dedupId = fallback.seedYtId;
|
|
usedYtFallback = true;
|
|
}
|
|
} else {
|
|
try {
|
|
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
|
|
} catch (err) {
|
|
Logger.warn(`[autoPlay] YouTube mix resolve throw: ${String(err)}`);
|
|
result = undefined;
|
|
}
|
|
}
|
|
|
|
let tracks: Track[] = [];
|
|
if (result?.loadType === LoadType.PLAYLIST) {
|
|
tracks = result.data.tracks;
|
|
} else if (result?.loadType === LoadType.SEARCH || Array.isArray(result?.data)) {
|
|
tracks = Array.isArray(result.data) ? result.data : [];
|
|
} else if (result?.loadType === LoadType.TRACK) {
|
|
tracks = [ result.data ];
|
|
}
|
|
Logger.info(`[autoPlay] 자동재생 후보 ${tracks.length}곡${usedYtFallback ? " (YouTube 폴백)" : ""}`);
|
|
if (tracks.length === 0) {
|
|
this.end();
|
|
return;
|
|
}
|
|
if (tracks.length > 0 && tracks[0].info.identifier === dedupId) tracks = tracks.slice(1);
|
|
if (tracks.length === 0) {
|
|
this.end();
|
|
return;
|
|
}
|
|
this.addTracks(tracks, "자동재생");
|
|
}
|
|
|
|
/**
|
|
* Spotify 트랙 메타데이터로 YouTube 검색 후 매칭된 곡의 RD 믹스를 가져옴.
|
|
* (현재 Lavalink에서 `ytmsearch:`는 EMPTY를 자주 반환하므로 `ytsearch:` 사용)
|
|
* 각 단계가 실패하면 다음 단계로 진행하지 않고 undefined 반환.
|
|
* 호출 측에서 RD 믹스 첫 곡(=시드 자체)을 중복 제거할 수 있도록 seed videoId도 같이 반환.
|
|
*/
|
|
private async youtubeMixFromSpotifyTrack(author: string, title: string) {
|
|
const cleanAuthor = author.replace(" - Topic", "");
|
|
const query = `ytsearch:${cleanAuthor} ${title}`;
|
|
let searchResult;
|
|
try {
|
|
searchResult = await this.player.node.rest.resolve(query);
|
|
} catch (err) {
|
|
Logger.warn(`[autoPlay] YouTube 폴백 검색 throw: ${String(err)}`);
|
|
return undefined;
|
|
}
|
|
if (
|
|
searchResult?.loadType !== LoadType.SEARCH
|
|
|| !Array.isArray(searchResult.data)
|
|
|| searchResult.data.length === 0
|
|
) {
|
|
Logger.warn(`[autoPlay] YouTube 폴백 검색 결과 없음 (query="${cleanAuthor} ${title}", loadType=${searchResult?.loadType ?? "(null)"})`);
|
|
return undefined;
|
|
}
|
|
const seedYtId = searchResult.data[0].info.identifier;
|
|
Logger.info(`[autoPlay] YouTube 폴백 매칭: videoId=${seedYtId}`);
|
|
try {
|
|
const result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${seedYtId}&list=RD${seedYtId}`);
|
|
return { result, seedYtId };
|
|
} catch (err) {
|
|
Logger.warn(`[autoPlay] YouTube 폴백 RD 믹스 resolve throw: ${String(err)}`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 재생 위치가 일정 시간 동안 전혀 진행되지 않으면(=버퍼링/멈춤) 다음 곡으로 넘긴다.
|
|
* Lavalink가 TrackStuckEvent를 못 던지는 트리클 스트리밍(느린 버퍼링) 상황까지 커버하는 백스탑.
|
|
*/
|
|
private startStallWatchdog() {
|
|
this.stopStallWatchdog();
|
|
this.lastPosition = this.position;
|
|
this.lastProgressAt = Date.now();
|
|
this.stallWatchdog = setInterval(() => {
|
|
if (this.isDead) {
|
|
this.stopStallWatchdog();
|
|
return;
|
|
}
|
|
// 재생 중이 아니거나 일시정지면 정상 상태 → 진행시각 리셋
|
|
if (!this.isPlaying || this.isPaused) {
|
|
this.lastPosition = this.player.position;
|
|
this.lastProgressAt = Date.now();
|
|
return;
|
|
}
|
|
const pos = this.player.position;
|
|
if (pos > this.lastPosition + 500) {
|
|
// 정상적으로 재생 위치가 진행됨
|
|
this.lastPosition = pos;
|
|
this.lastProgressAt = Date.now();
|
|
return;
|
|
}
|
|
// 재생 위치가 StallThresholdMs 동안 전혀 진행되지 않음 → 멈춤으로 판단하고 스킵
|
|
if (Date.now() - this.lastProgressAt >= StallThresholdMs) {
|
|
Logger.error(`[GuildPlayer] 재생 위치가 ${StallThresholdMs}ms 동안 진행되지 않음(멈춤 감지). 다음 곡으로 넘어갑니다. (position=${pos}ms)`);
|
|
this.lastPosition = pos;
|
|
this.lastProgressAt = Date.now();
|
|
// stopTrack → end 이벤트 → 다음 곡 진행
|
|
this.player.stopTrack().catch((err) => {
|
|
Logger.error(`[GuildPlayer] 멈춤 감지 후 stopTrack 에러: ${String(err)}`);
|
|
});
|
|
}
|
|
}, StallCheckIntervalMs);
|
|
}
|
|
|
|
private stopStallWatchdog() {
|
|
if (this.stallWatchdog !== undefined) {
|
|
clearInterval(this.stallWatchdog);
|
|
this.stallWatchdog = undefined;
|
|
}
|
|
}
|
|
|
|
private clearAllTimers() {
|
|
if (this.errorTimer !== undefined) {
|
|
clearTimeout(this.errorTimer);
|
|
this.errorTimer = undefined;
|
|
}
|
|
if (this.endTimer !== undefined) {
|
|
clearTimeout(this.endTimer);
|
|
this.endTimer = undefined;
|
|
}
|
|
if (this.closedTimer !== undefined) {
|
|
clearTimeout(this.closedTimer);
|
|
this.closedTimer = undefined;
|
|
}
|
|
this.stopStallWatchdog();
|
|
}
|
|
|
|
public end() {
|
|
this.clearAllTimers();
|
|
this.endTimer = setTimeout(() => {
|
|
this.endTimer = undefined;
|
|
this.delete(true);
|
|
}, idleEndTime);
|
|
this.lastPlayedTrack = undefined;
|
|
this.queue = [];
|
|
this.canRecommend = true;
|
|
this.setMsg();
|
|
}
|
|
|
|
public delete(afterEnd?: boolean) {
|
|
if (this.isDead) return;
|
|
if (!afterEnd) this.end();
|
|
this.isDead = true;
|
|
this.clearAllTimers();
|
|
this.player.destroy().catch((err) => {
|
|
Logger.error(`[GuildPlayer] player.destroy 에러: ${String(err)}`);
|
|
});
|
|
lavalinkManager.delPlayer(this.guild.id);
|
|
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
|
}
|
|
|
|
public async setMsg(update: { player: boolean; queue: boolean; } = { player: true, queue: true }) {
|
|
if (update.player) Redis?.publishState("player_update", { guildId: this.guild.id });
|
|
if (update.queue) Redis?.publishState("queue_update", { guildId: this.guild.id });
|
|
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
|
|
if (!check) return;
|
|
this.textChannel = channel;
|
|
this.msg = msg;
|
|
if (!this.msg) return;
|
|
|
|
// content
|
|
let content = default_content;
|
|
let realQueue = this.queue.slice(1);
|
|
if (realQueue.length > 0) {
|
|
content = "__**대기열 목록:**__\n";
|
|
let list: string[] = [];
|
|
let nowLength = content.length + 50; // 50자 여유두기
|
|
for (let i=0; i<realQueue.length; i++) {
|
|
let track = realQueue[i];
|
|
let text = `${
|
|
(i+1).toString().padStart(realQueue.length.toString().length, "0")
|
|
}\\. ${
|
|
track.info.author.replace(" - Topic","")
|
|
} - ${
|
|
track.info.title
|
|
} [${
|
|
timeFormat(track.info.length)
|
|
}] ~ ${
|
|
track.userId === "자동재생" ? "자동재생" : `<@${track.userId}>`
|
|
}`;
|
|
if (nowLength + text.length > 2000) {
|
|
content += `+ ${realQueue.length-list.length}곡\n`;
|
|
break;
|
|
}
|
|
nowLength += text.length;
|
|
list.push(text);
|
|
}
|
|
content += list.reverse().join("\n");
|
|
}
|
|
|
|
// embed
|
|
const embed = (this.isPlaying || this.isPaused) && !!this.nowTrack
|
|
? client.mkembed({
|
|
title: `**[${timeFormat(this.nowTrack.info.length)}] ${this.nowTrack.info.author.replace(" - Topic","")} - ${this.nowTrack.info.title}**`,
|
|
description: `노래 요청자: ${this.nowTrack.userId === "자동재생" ? "자동재생" : `<@${this.nowTrack.userId}>`}`,
|
|
image: this.nowTrack.info.artworkUrl,
|
|
url: this.nowTrack.info.author.includes(" - Topic") ? this.nowTrack.info.uri?.replace("www.youtube","music.youtube") : this.nowTrack.info.uri,
|
|
footer: { text: `대기열: ${realQueue.length}개 | 볼륨: ${this.player.volume}${
|
|
this.isRecommend ? " | 자동재생: 활성화" : ""
|
|
}${
|
|
this.isPaused ? " | 노래가 일시중지 되었습니다." : ""
|
|
}` }
|
|
})
|
|
: default_embed(this.guild.id);
|
|
|
|
this.msg.edit({
|
|
content: content,
|
|
embeds: [ embed ],
|
|
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
|
|
components: [ getButtons(
|
|
this.isPlaying || this.isPaused,
|
|
this.isPlaying && this.isPaused,
|
|
realQueue.length > 1,
|
|
) ]
|
|
});
|
|
}
|
|
|
|
public async errMsg(reason: string, retry: boolean = true) {
|
|
if (!this.msg) return null;
|
|
await this.msg.edit({
|
|
content: this.msg.content,
|
|
embeds: [ client.mkembed({
|
|
title: "오류발생!!",
|
|
description: reason,
|
|
color: "DarkRed",
|
|
}) ],
|
|
files: [],
|
|
components: [ getButtons(
|
|
false,
|
|
false,
|
|
this.queue.length > 1,
|
|
) ]
|
|
});
|
|
// retry=true일 때만 5초 뒤 현재 queue[0]을 재생. 재생 실패(exception)처럼 Lavalink가
|
|
// 이미 end 이벤트로 다음 곡을 진행시키는 경우엔 retry=false로 호출해 중복 진행을 막는다.
|
|
if (retry) {
|
|
this.errorTimer = setTimeout(() => {
|
|
this.errorTimer = undefined;
|
|
this.playNext();
|
|
}, DelayAfterErrMs);
|
|
}
|
|
return null;
|
|
}
|
|
} |