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