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 단축).
This commit is contained in:
@@ -300,7 +300,9 @@ export class GuildPlayer {
|
|||||||
public async playNext() {
|
public async playNext() {
|
||||||
const track = this.queue[0];
|
const track = this.queue[0];
|
||||||
if (!track) return this.end();
|
if (!track) return this.end();
|
||||||
|
const tResolve = Date.now();
|
||||||
const encoded = await this.getResolved(track);
|
const encoded = await this.getResolved(track);
|
||||||
|
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
|
||||||
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
|
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
|
||||||
this.prefetch(this.queue[1]);
|
this.prefetch(this.queue[1]);
|
||||||
await this.player.playTrack({ track: { encoded } });
|
await this.player.playTrack({ track: { encoded } });
|
||||||
|
|||||||
@@ -102,14 +102,16 @@ export class LavalinkManager {
|
|||||||
this.players.delete(guildId);
|
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<string> } | null> {
|
||||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||||
if (!node) {
|
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
||||||
this.delPlayer(guildId);
|
|
||||||
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
|
||||||
}
|
|
||||||
player = player ?? this.getPlayer(guildId);
|
|
||||||
if (!player) return;
|
|
||||||
const { isUrl, text, flags } = parseLink(query.trim());
|
const { isUrl, text, flags } = parseLink(query.trim());
|
||||||
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
||||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||||
@@ -133,29 +135,69 @@ export class LavalinkManager {
|
|||||||
const result = await node.rest.resolve(searchText);
|
const result = await node.rest.resolve(searchText);
|
||||||
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
||||||
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
|
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<string> },
|
||||||
|
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(`노래를 찾을수 없습니다.`);
|
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.loadType === LoadType.PLAYLIST) {
|
await this.addResolved(player, resolved, userId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async youtubeSearch(query: string): Promise<Track[]> {
|
public async youtubeSearch(query: string): Promise<Track[]> {
|
||||||
|
|||||||
@@ -73,16 +73,29 @@ class RedisClientClass {
|
|||||||
const guild = await getGuildById(data.serverId);
|
const guild = await getGuildById(data.serverId);
|
||||||
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
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: "이 서버의 멤버가 아닙니다." }));
|
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);
|
let player = lavalinkManager.getPlayer(guild.id);
|
||||||
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||||
if (!player) {
|
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;
|
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||||
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||||
|
await resolving;
|
||||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
||||||
}
|
}
|
||||||
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
const tJoined = Date.now();
|
||||||
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
|
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: "노래 추가 완료" }));
|
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
|
||||||
}
|
}
|
||||||
if (data.action === "player_playlist") {
|
if (data.action === "player_playlist") {
|
||||||
@@ -92,16 +105,25 @@ class RedisClientClass {
|
|||||||
const guild = await getGuildById(data.serverId);
|
const guild = await getGuildById(data.serverId);
|
||||||
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
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: "이 서버의 멤버가 아닙니다." }));
|
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);
|
let player = lavalinkManager.getPlayer(guild.id);
|
||||||
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||||
if (!player) {
|
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;
|
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||||
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||||
|
await resolvingPl;
|
||||||
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
|
||||||
}
|
}
|
||||||
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
|
||||||
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
|
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: "플레이리스트 추가 완료" }));
|
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
|
||||||
}
|
}
|
||||||
if (data.action === "player_now") {
|
if (data.action === "player_now") {
|
||||||
|
|||||||
Reference in New Issue
Block a user