Compare commits
6 Commits
083b54e14e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a81ce7416 | ||
|
|
d335287e7e | ||
|
|
67ff52227d | ||
|
|
09684b2070 | ||
|
|
e8c63a2118 | ||
|
|
5756f4e10a |
@@ -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`;
|
||||||
|
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 {
|
} else {
|
||||||
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user