Files
music_bot_v2/bot/src/classes/GuildPlayer.ts
tkrmagid 6de959be43 perf(play): 음성접속과 곡 해석 병렬화 + 첫 곡 yt-dlp 예열로 첫 재생 단축
기존: 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 단축).
2026-09-17 13:11:11 +09:00

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;
}
}