Compare commits

...

6 Commits

Author SHA1 Message Date
tkrmagid
5a81ce7416 fix: messageCreate의 미await 비동기 호출로 인한 unhandledRejection 추가 차단
기존 player가 있어 channelJoin을 건너뛰는 경로에서 search()가 노드 미연결로
reject될 때 handleMessage는 이미 종료돼 .catch가 잡지 못하던 문제 수정.
- handleMessage 내부 search() await 처리
- prefix 명령어 경로의 messageRun() await 처리 (try/catch가 비동기 오류도 포착)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 01:35:45 +09:00
tkrmagid
d335287e7e fix: Lavalink 노드 미연결 시 unhandledRejection 방지
노드가 CONNECTED 상태가 아닐 때 joinVoiceChannel이 throw 되어
fire-and-forget handleMessage에서 처리되지 않은 Promise 거부로 번지던 문제 수정.
- LavalinkManager.hasReadyNode() 추가
- channelJoin에서 노드 미연결 시 안내 임베드 반환 + join try/catch
- messageCreate의 handleMessage 호출에 .catch 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 01:32:32 +09:00
tkrmagid
67ff52227d Merge dev into master: 폴백 dedup ID 수정 2026-05-27 20:37:27 +09:00
tkrmagid
09684b2070 bot: Spotify→YouTube 폴백 dedup ID를 YouTube seed로 사용
폴백 시 lastPlayed.identifier(Spotify ID)와 RD 믹스 첫 곡(YouTube ID)
비교가 안 맞아서, 방금 끝난 곡의 YouTube 버전이 자동재생 큐에 그대로
들어가던 버그 수정.

- youtubeMixFromSpotifyTrack: { result, seedYtId } 반환
- autoPlay: 폴백일 때 dedup 기준 ID를 seedYtId로 교체
2026-05-27 20:37:22 +09:00
tkrmagid
e8c63a2118 Merge dev into master: Spotify 자동재생 폴백 2026-05-27 20:33:54 +09:00
tkrmagid
5756f4e10a bot: Spotify 자동재생 폴백 (sprec EMPTY → YouTube RD 믹스)
- sprec이 EMPTY/ERROR/빈 PLAYLIST면 곡 메타데이터(artist, title)로
  ytsearch 후 첫 결과의 videoId로 RD<id> 믹스를 가져와 자동재생 큐 채움
- 리뷰어 확인: ytmsearch는 현재 Lavalink에서 EMPTY를 반환해서 ytsearch 사용
- sprec / 검색 / 믹스 resolve 각 단계에 try/catch 추가 (한 단계 실패 시
  다음 단계로 진행하지 않고 폴백 또는 end로 안전하게 빠짐)
- 진단 로깅: sprec loadType, 폴백 사유, 검색 매칭 videoId, 최종 후보 곡수
2026-05-27 20:33:28 +09:00
4 changed files with 115 additions and 10 deletions

View File

@@ -275,12 +275,50 @@ export class GuildPlayer {
if (!this.lastPlayedTrack) return; if (!this.lastPlayedTrack) return;
const source = this.lastPlayedTrack.info.sourceName; const source = this.lastPlayedTrack.info.sourceName;
const trackId = this.lastPlayedTrack.info.identifier; const trackId = this.lastPlayedTrack.info.identifier;
const title = this.lastPlayedTrack.info.title;
const author = this.lastPlayedTrack.info.author;
let result; let result;
let usedYtFallback = false;
// 중복 제거 기준 ID. 기본은 lastPlayed의 identifier, 다만 Spotify→YouTube 폴백 시에는
// RD 믹스의 시드(YouTube videoId)와 비교해야 첫 곡(=방금 끝난 곡의 YouTube 버전)을 제거할 수 있음.
let dedupId = trackId;
if (source === "spotify") { if (source === "spotify") {
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`); const sprecUri = `sprec:seed_tracks=${trackId}&limit=11`;
} else { try {
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`); 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[] = []; let tracks: Track[] = [];
if (result?.loadType === LoadType.PLAYLIST) { if (result?.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks; tracks = result.data.tracks;
@@ -289,11 +327,12 @@ export class GuildPlayer {
} else if (result?.loadType === LoadType.TRACK) { } else if (result?.loadType === LoadType.TRACK) {
tracks = [ result.data ]; tracks = [ result.data ];
} }
Logger.info(`[autoPlay] 자동재생 후보 ${tracks.length}${usedYtFallback ? " (YouTube 폴백)" : ""}`);
if (tracks.length === 0) { if (tracks.length === 0) {
this.end(); this.end();
return; return;
} }
if (tracks.length > 0 && tracks[0].info.identifier === trackId) tracks = tracks.slice(1); if (tracks.length > 0 && tracks[0].info.identifier === dedupId) tracks = tracks.slice(1);
if (tracks.length === 0) { if (tracks.length === 0) {
this.end(); this.end();
return; return;
@@ -301,6 +340,41 @@ export class GuildPlayer {
this.addTracks(tracks, "자동재생"); 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;
}
}
private clearAllTimers() { private clearAllTimers() {
if (this.errorTimer !== undefined) { if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer); clearTimeout(this.errorTimer);

View File

@@ -31,6 +31,11 @@ export class LavalinkManager {
}); });
} }
/** 연결 가능한(CONNECTED 상태) Lavalink 노드가 하나라도 있는지 확인 */
hasReadyNode(): boolean {
return !!this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
}
getPlayer(guildId: string) { getPlayer(guildId: string) {
return this.players.get(guildId); return this.players.get(guildId);
} }

View File

@@ -3,6 +3,7 @@ import { client, lavalinkManager } from "../index";
import { Command } from "../types/Command"; import { Command } from "../types/Command";
import { GuildPlayer } from "../classes/GuildPlayer"; import { GuildPlayer } from "../classes/GuildPlayer";
import { getTextChannelAndMsg } from "../utils/music/Channel"; import { getTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger";
/** join 명령어 */ /** join 명령어 */
export default class implements Command { export default class implements Command {
@@ -73,15 +74,38 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string |
let player = lavalinkManager.getPlayer(guild.id); let player = lavalinkManager.getPlayer(guild.id);
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player }; if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player };
player = new GuildPlayer(
guild, // 연결 가능한 Lavalink 노드가 없으면 joinVoiceChannel이 throw 되어 unhandledRejection으로 번짐.
await lavalinkManager.shoukaku.joinVoiceChannel({ // 노드 미연결 시 사용자에게 안내만 하고 종료.
if (!lavalinkManager.hasReadyNode()) {
return { embed: client.mkembed({
title: "음악 서버에 연결할 수 없습니다.",
description: "잠시 후 다시 시도해주세요.",
color: "DarkRed",
}) };
}
let voicePlayer;
try {
voicePlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
guildId: guild.id, guildId: guild.id,
channelId: voiceChannel.id, channelId: voiceChannel.id,
shardId: guild.shardId, shardId: guild.shardId,
deaf: true, deaf: true,
mute: false, mute: false,
}), });
} catch (err) {
Logger.error(`[channelJoin] 음성채널 참가 실패: ${String(err)}`);
return { embed: client.mkembed({
title: "음성채널 참가에 실패했습니다.",
description: "잠시 후 다시 시도해주세요.",
color: "DarkRed",
}) };
}
player = new GuildPlayer(
guild,
voicePlayer,
voiceChannel.id, voiceChannel.id,
channel, channel,
msg, msg,

View File

@@ -19,7 +19,9 @@ const cmdErr = (message: Message, commandName: string | undefined | null): void
export const messageCreate = async (message: Message): Promise<void> => { export const messageCreate = async (message: Message): Promise<void> => {
if (message.author.bot || message.channel.type === ChannelType.DM) return; if (message.author.bot || message.channel.type === ChannelType.DM) return;
if (!message.content.startsWith(client.prefix)) { if (!message.content.startsWith(client.prefix)) {
handleMessage(message); handleMessage(message).catch((err) => {
Logger.error(`[messageCreate] handleMessage 처리 중 에러: ${String(err)}`);
});
return; return;
} }
@@ -33,7 +35,7 @@ export const messageCreate = async (message: Message): Promise<void> => {
cmdErr(message, commandName); cmdErr(message, commandName);
return client.msgDelete(message, 0, true); return client.msgDelete(message, 0, true);
} }
command.messageRun(message, args); await command.messageRun(message, args);
client.msgDelete(message, 0, true); client.msgDelete(message, 0, true);
} catch(err: any) { } catch(err: any) {
if (Config.debug) Logger.error(err); if (Config.debug) Logger.error(err);
@@ -78,6 +80,6 @@ async function handleMessage(message: Message): Promise<void> {
}) ] }).then(m => client.msgDelete(m, 1)); }) ] }).then(m => client.msgDelete(m, 1));
return client.msgDelete(message, 100, true); return client.msgDelete(message, 100, true);
} }
lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player); await lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player);
return client.msgDelete(message, 100, true); return client.msgDelete(message, 100, true);
} }