feat(bot): 스포티파이 재생목록을 곡 정보만 즉시 대기열에 넣고 재생 직전에 유튜브로 변환
- 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:<id>)을 바로 추가
(기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초)
- GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치)
- 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록
- 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체
- '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열
- queue_set: JSON으로 깨진 _resolved({}) 제거
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { shuffle } from "../utils/Shuffle";
|
||||
import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
|
||||
const DelayAfterErrMs = 1000 * 5;
|
||||
const idleEndTime = 1000 * 60 * 10;
|
||||
@@ -78,8 +79,10 @@ export class GuildPlayer {
|
||||
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;
|
||||
if (reEncoded) {
|
||||
await this.player.playTrack({ track: { encoded: reEncoded } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
|
||||
@@ -194,8 +197,8 @@ export class GuildPlayer {
|
||||
await this.player.setGlobalVolume(savedVolume || 50);
|
||||
|
||||
// 곡이 있었으면 이어서 재생
|
||||
if (currentTrack) {
|
||||
const reEncoded = await this.resolvePlayable(currentTrack);
|
||||
const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
|
||||
if (currentTrack && reEncoded) {
|
||||
await this.player.playTrack({
|
||||
track: { encoded: reEncoded },
|
||||
position: currentPosition,
|
||||
@@ -257,27 +260,45 @@ export class GuildPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
|
||||
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */
|
||||
/** 유튜브(및 스포티파이) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
|
||||
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백.
|
||||
* 스포티파이 곡은 여기서(재생 직전에) 유튜브 원곡을 찾아 그 오디오를 쓴다. 재생목록 항목은 대기열에
|
||||
* 곡 정보만 들어 있어서, 원곡을 못 찾으면 재생할 수 없음을 뜻하는 빈 문자열을 돌려준다. */
|
||||
private async resolvePlayable(track: QueueTrack): Promise<string> {
|
||||
let source: Track = track;
|
||||
let fallback = Spotify.isLazyTrack(track) ? "" : track.encoded;
|
||||
try {
|
||||
const info: any = (track as any).info ?? {};
|
||||
if (track.info.sourceName === "spotify") {
|
||||
const matched = await lavalinkManager.matchSpotifyTrack(track);
|
||||
if (matched) {
|
||||
source = matched;
|
||||
fallback = matched.encoded;
|
||||
// 재생목록 커버를 임시로 쓰던 곡은 찾은 곡의 이미지로 바꾼다(앨범은 앨범 커버가 맞으므로 유지)
|
||||
if (!track.info.artworkUrl || (track.pluginInfo as any)?.coverIsPlaylist) {
|
||||
track.info.artworkUrl = matched.info.artworkUrl ?? track.info.artworkUrl;
|
||||
}
|
||||
} else if (!fallback) {
|
||||
Logger.warn(`[GuildPlayer] 유튜브에서 같은 곡을 찾지 못해 건너뜁니다: "${track.info.author} - ${track.info.title}"`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const info: any = (source as any).info ?? {};
|
||||
const src = info.sourceName;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return fallback;
|
||||
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
|
||||
if (!target) return track.encoded;
|
||||
if (!target) return fallback;
|
||||
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
|
||||
if (!r.ok) return track.encoded;
|
||||
if (!r.ok) return fallback;
|
||||
const data = (await r.json()) as { url?: string };
|
||||
if (!data.url) return track.encoded;
|
||||
if (!data.url) return fallback;
|
||||
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
|
||||
if (!node) return track.encoded;
|
||||
if (!node) return fallback;
|
||||
const res = await node.rest.resolve(data.url);
|
||||
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
|
||||
return track.encoded;
|
||||
return fallback;
|
||||
} catch (e) {
|
||||
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
|
||||
return track.encoded;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +308,7 @@ export class GuildPlayer {
|
||||
track._resolved = this.resolvePlayable(track).catch((e) => {
|
||||
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
|
||||
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
|
||||
return track.encoded;
|
||||
return Spotify.isLazyTrack(track) ? "" : track.encoded;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,12 +318,19 @@ export class GuildPlayer {
|
||||
return track._resolved!;
|
||||
}
|
||||
|
||||
public async playNext() {
|
||||
public async playNext(): Promise<void> {
|
||||
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에 가까움)`);
|
||||
if (!encoded) {
|
||||
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
|
||||
if (this.isDead || this.queue[0] !== track) return;
|
||||
this.queue.shift();
|
||||
if (this.queue.length === 0 && this.isRecommend && this.canRecommend && this.lastPlayedTrack) return this.autoPlay();
|
||||
return this.playNext();
|
||||
}
|
||||
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
|
||||
this.prefetch(this.queue[1]);
|
||||
await this.player.playTrack({ track: { encoded } });
|
||||
|
||||
@@ -11,12 +11,16 @@ import { Spotify } from "../utils/api/Spotify";
|
||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
|
||||
|
||||
/** 해석 결과. pendingSpotify: 스포티파이 재생목록에서 아직 유튜브로 매칭하지 않은 나머지 곡(큐 추가 후 백그라운드 매칭). */
|
||||
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; pendingSpotify?: SongMeta[] };
|
||||
/** 해석 결과. */
|
||||
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string> };
|
||||
|
||||
/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */
|
||||
const MatchCacheSize = 500;
|
||||
|
||||
export class LavalinkManager {
|
||||
public shoukaku: Shoukaku;
|
||||
private players: Map<string, GuildPlayer> = new Map();
|
||||
private matchCache: Map<string, Promise<Track | null>> = new Map();
|
||||
|
||||
constructor(readonly client: Client) {
|
||||
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
|
||||
@@ -117,18 +121,18 @@ export class LavalinkManager {
|
||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
||||
const { isUrl, text, flags } = parseLink(query.trim());
|
||||
// 스포티파이 플레이리스트/앨범: Lavalink(LavaSrc)는 Spotify API 403으로 실패하므로 봇이 곡 목록을 직접 받아
|
||||
// 곡마다 유튜브 원곡을 매칭한다. 첫 곡만 지금 매칭해 바로 재생하고, 나머지는 큐 추가 후 백그라운드에서 이어 붙인다.
|
||||
// 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
|
||||
// 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 그래서 100곡짜리도 곡 목록을
|
||||
// 받는 시간(~0.4초)만 기다리면 된다. 임베드 조회가 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
|
||||
const collection = isUrl ? Spotify.parseCollection(text) : null;
|
||||
if (collection) {
|
||||
const entries = await Spotify.getCollectionTracks(collection.kind, collection.id);
|
||||
if (flags.has('s')) shuffle(entries);
|
||||
let first: Track | null = null;
|
||||
let i = 0;
|
||||
while (!first && i < entries.length && i < 5) first = await this.findYoutubeMatch(entries[i++]);
|
||||
if (!first) return null;
|
||||
this.prewarmTrack(first);
|
||||
return { tracks: [first], playlist: true, flags, pendingSpotify: entries.slice(i) };
|
||||
const { tracks: entries, cover } = await Spotify.getCollectionTracks(collection.kind, collection.id);
|
||||
if (entries.length > 0) {
|
||||
const tracks = entries.map((e) => Spotify.toLazyTrack(e, cover, collection.kind === "playlist"));
|
||||
if (flags.has('s')) shuffle(tracks);
|
||||
this.prewarmTrack(tracks[0]);
|
||||
return { tracks, playlist: true, flags };
|
||||
}
|
||||
}
|
||||
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||
@@ -177,6 +181,7 @@ export class LavalinkManager {
|
||||
if (result.loadType === LoadType.PLAYLIST) {
|
||||
tracks = result.data.tracks;
|
||||
playlist = true;
|
||||
if (flags.has('s')) shuffle(tracks);
|
||||
} else if (result.loadType === LoadType.TRACK) {
|
||||
tracks = [result.data];
|
||||
} else if (result.loadType === LoadType.SEARCH) {
|
||||
@@ -197,11 +202,8 @@ export class LavalinkManager {
|
||||
userId: string,
|
||||
) {
|
||||
if (resolved.playlist) {
|
||||
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
|
||||
if (resolved.pendingSpotify?.length) {
|
||||
this.appendSpotifyEntries(player, resolved.pendingSpotify, userId)
|
||||
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 추가 실패: ${String(e)}`));
|
||||
}
|
||||
// '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
|
||||
await player.addTracks(resolved.tracks, userId);
|
||||
return;
|
||||
}
|
||||
await player.addTrack(resolved.tracks[0], userId);
|
||||
@@ -226,22 +228,20 @@ export class LavalinkManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 스포티파이 재생목록의 나머지 곡을 순서대로 매칭해 큐에 이어 붙인다. 정지/퇴장하면 중단. */
|
||||
private async appendSpotifyEntries(player: GuildPlayer, entries: SongMeta[], userId: string) {
|
||||
const chunkSize = 5;
|
||||
let added = 0;
|
||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||
const found = await Promise.all(entries.slice(i, i + chunkSize).map((e) => this.findYoutubeMatch(e)));
|
||||
// 그 사이 정지(큐 비움)했거나 봇이 나갔으면 더 붙이지 않는다
|
||||
if (this.getPlayer(player.guild.id) !== player || player.queue.length === 0) {
|
||||
Logger.info(`[LavalinkManager] 스포티파이 재생목록 추가 중단 (정지/퇴장), ${added}곡 추가됨`);
|
||||
return;
|
||||
}
|
||||
const tracks = found.filter((t): t is Track => !!t);
|
||||
if (tracks.length > 0) await player.addTracks(tracks, userId);
|
||||
added += tracks.length;
|
||||
}
|
||||
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}/${entries.length}곡 추가 완료`);
|
||||
/**
|
||||
* 스포티파이 곡(재생목록 항목·자동재생 추천곡)을 재생할 유튜브 원곡 트랙으로 바꾼다. 못 찾으면 null.
|
||||
* 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다.
|
||||
*/
|
||||
public matchSpotifyTrack(track: Track): Promise<Track | null> {
|
||||
const key = track.info.identifier;
|
||||
const cached = this.matchCache.get(key);
|
||||
if (cached) return cached;
|
||||
const pending = this.findYoutubeMatch({ title: track.info.title, artist: track.info.author, durationMs: track.info.length });
|
||||
this.matchCache.set(key, pending);
|
||||
// 못 찾은 결과는 남기지 않는다(다음에 다시 시도)
|
||||
pending.then((t) => { if (!t && this.matchCache.get(key) === pending) this.matchCache.delete(key); }, () => this.matchCache.delete(key));
|
||||
if (this.matchCache.size > MatchCacheSize) this.matchCache.delete(this.matchCache.keys().next().value!);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +252,12 @@ export class LavalinkManager {
|
||||
try {
|
||||
const info: any = (track as any).info ?? {};
|
||||
const src = info.sourceName;
|
||||
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
|
||||
// 스포티파이 곡은 유튜브 원곡 매칭부터 예열하고, 찾으면 그 유튜브 주소를 리졸브해 둔다.
|
||||
if (src === "spotify") {
|
||||
this.matchSpotifyTrack(track).then((m) => { if (m) this.prewarmTrack(m); }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (src !== "youtube" && 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(() => {});
|
||||
|
||||
@@ -172,7 +172,10 @@ class RedisClientClass {
|
||||
context.player.queue.length = 0;
|
||||
if (nowTrack) context.player.queue.push(nowTrack);
|
||||
for (const rawTrack of data.newQueue) {
|
||||
if (rawTrack.encoded) context.player.queue.push(rawTrack);
|
||||
if (!rawTrack?.encoded) continue;
|
||||
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
|
||||
const { _resolved, ...track } = rawTrack;
|
||||
context.player.queue.push(track);
|
||||
}
|
||||
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||
context.player.setMsg();
|
||||
|
||||
Reference in New Issue
Block a user