From 6de959be43cccee4ae832921709e3b71a9e6032f Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Thu, 17 Sep 2026 13:11:11 +0900 Subject: [PATCH] =?UTF-8?q?perf(play):=20=EC=9D=8C=EC=84=B1=EC=A0=91?= =?UTF-8?q?=EC=86=8D=EA=B3=BC=20=EA=B3=A1=20=ED=95=B4=EC=84=9D=20=EB=B3=91?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20+=20=EC=B2=AB=20=EA=B3=A1=20yt-dlp=20?= =?UTF-8?q?=EC=98=88=EC=97=B4=EB=A1=9C=20=EC=B2=AB=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존: 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 단축). --- bot/src/classes/GuildPlayer.ts | 2 + bot/src/classes/LavalinkManager.ts | 94 +++++++++++++++++++++--------- bot/src/classes/RedisClient.ts | 34 +++++++++-- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index c09c314..a78433f 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -300,7 +300,9 @@ export class GuildPlayer { 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 } }); diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index 35df7ae..c5fde02 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -102,14 +102,16 @@ export class LavalinkManager { this.players.delete(guildId); } - async search(guildId: string, query: string, userId: string, player?: GuildPlayer) { + /** + * 쿼리를 Lavalink 트랙으로 '해석'만 한다. 플레이어(=음성접속)가 필요 없으므로 + * 음성채널 접속과 동시에 돌릴 수 있다(첫 재생 지연 단축). + * 해석이 끝나면 첫 곡의 yt-dlp 리졸브를 즉시 예열(prewarm)해, 음성접속이 끝날 즈음엔 + * 오디오 URL이 이미 준비되도록 한다. + * 못 찾으면 null. + */ + public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set } | null> { const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); - if (!node) { - this.delPlayer(guildId); - throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); - } - player = player ?? this.getPlayer(guildId); - if (!player) return; + if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); const { isUrl, text, flags } = parseLink(query.trim()); // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. @@ -133,29 +135,69 @@ export class LavalinkManager { const result = await node.rest.resolve(searchText); if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) { if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`); + return null; + } + let tracks: Track[] = []; + let playlist = false; + if (result.loadType === LoadType.PLAYLIST) { + tracks = result.data.tracks; + playlist = true; + } else if (result.loadType === LoadType.TRACK) { + tracks = [result.data]; + } else if (result.loadType === LoadType.SEARCH) { + tracks = result.data.length > 0 ? [result.data[0]] : []; + } + if (tracks.length === 0) return null; + // 첫 곡 오디오(yt-dlp) 리졸브를 지금 바로 걸어둔다 — 음성접속과 겹쳐 진행된다. + this.prewarmTrack(tracks[0]); + return { tracks, playlist, flags }; + } + + /** + * resolveQuery 결과를 플레이어 큐에 반영한다(기존 search 후반부와 동일 동작). + */ + public async addResolved( + player: GuildPlayer, + resolved: { tracks: Track[]; playlist: boolean; flags: Set }, + userId: string, + ) { + if (resolved.playlist) { + await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId); + return; + } + await player.addTrack(resolved.tracks[0], userId); + } + + /** + * 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget). + * GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다. + */ + public prewarmTrack(track: Track) { + try { + const info: any = (track as any).info ?? {}; + const src = info.sourceName; + if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return; + const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim(); + if (!target) return; + fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {}); + } catch { /* prewarm 실패는 무시 (실제 재생 시 정상 경로로 리졸브) */ } + } + + async search(guildId: string, query: string, userId: string, player?: GuildPlayer) { + const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); + if (!node) { + this.delPlayer(guildId); + throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); + } + player = player ?? this.getPlayer(guildId); + if (!player) return; + const resolved = await this.resolveQuery(query); + if (!resolved) { // 노래 못찾았을때 player.errMsg(`노래를 찾을수 없습니다.`); return; } - if (result.loadType === LoadType.PLAYLIST) { - player.addTracks( - flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks, - userId - ); - return; - } - if (result.loadType === LoadType.TRACK) { - player.addTrack(result.data, userId); - return; - } - if (result.loadType === LoadType.SEARCH) { - if (result.data.length === 0) { - player.errMsg(`노래를 찾을수 없습니다.`); - return; - } - player.addTrack(result.data[0], userId); - return; - } + await this.addResolved(player, resolved, userId); } public async youtubeSearch(query: string): Promise { diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 906d0e9..72030fb 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -73,16 +73,29 @@ class RedisClientClass { const guild = await getGuildById(data.serverId); if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + // 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다. + const t0 = Date.now(); + const resolving = lavalinkManager.resolveQuery(data.track.url) + .catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; }); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { - if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); } player = (await channelJoin(guild, voiceChannel.id)).player; } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); - await lavalinkManager.search(guild.id, data.track.url, data.userId, player); + const tJoined = Date.now(); + if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); } + const resolved = await resolving; + const tResolved = Date.now(); + if (!resolved) { + player.errMsg(`노래를 찾을수 없습니다.`); + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." })); + } + await lavalinkManager.addResolved(player, resolved, data.userId); + Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`); await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); } if (data.action === "player_playlist") { @@ -92,16 +105,25 @@ class RedisClientClass { const guild = await getGuildById(data.serverId); if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); + // 재생목록 해석도 음성접속과 병렬로 진행한다. + const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl) + .catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; }); let player = lavalinkManager.getPlayer(guild.id); const voiceChannel = await getVoiceChannelById(guild, data.userId); if (!player) { - if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); } player = (await channelJoin(guild, voiceChannel.id)).player; } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { + await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); } - if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); - await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player); + if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); } + const resolvedPl = await resolvingPl; + if (!resolvedPl) { + player.errMsg(`노래를 찾을수 없습니다.`); + return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." })); + } + await lavalinkManager.addResolved(player, resolvedPl, data.userId); await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); } if (data.action === "player_now") {