From c75e42e10eb048180cecfc032bd158f3fca73f0b Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Sat, 3 Oct 2026 18:02:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(bot):=20=EC=8A=A4=ED=8F=AC=ED=8B=B0?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=20=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A1=20=EC=A0=95=EB=B3=B4=EB=A7=8C=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=20=EB=8C=80=EA=B8=B0=EC=97=B4=EC=97=90=20=EB=84=A3?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=AC=EC=83=9D=20=EC=A7=81=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=9C=A0=ED=8A=9C=EB=B8=8C=EB=A1=9C=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:)을 바로 추가 (기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초) - GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치) - 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록 - 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체 - '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열 - queue_set: JSON으로 깨진 _resolved({}) 제거 Co-Authored-By: Claude Opus 5.5 --- bot/README.md | 4 ++ bot/src/classes/GuildPlayer.ts | 60 +++++++++++++++++------- bot/src/classes/LavalinkManager.ts | 73 ++++++++++++++++-------------- bot/src/classes/RedisClient.ts | 5 +- bot/src/utils/api/Spotify.ts | 67 ++++++++++++++++++++++----- 5 files changed, 147 insertions(+), 62 deletions(-) diff --git a/bot/README.md b/bot/README.md index ac80fad..2eb4fc1 100644 --- a/bot/README.md +++ b/bot/README.md @@ -142,6 +142,10 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사. - 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가. - 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글. - 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트. +- 스포티파이 재생목록/앨범: 공개 임베드 페이지에서 곡 목록(최대 100곡)을 받아 **곡 정보만** 곧바로 대기열에 넣는다(~0.4초). + 유튜브 원곡 매칭은 곡마다 **재생 직전**에 한다(`GuildPlayer.resolvePlayable` → `LavalinkManager.matchSpotifyTrack`). + 다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다. 임베드 조회가 실패하면 Lavalink(LavaSrc)로 불러온다. + 스포티파이 음원 자체를 직접 재생하지는 않는다(LavaSrc도 항상 다른 음원으로 미러링하며, 직접 재생은 Premium 계정 + librespot이 필요). - 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백). ### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임) diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index a78433f..a4fcd36 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -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 { + 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 { 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 } }); diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index 04f3f82..55fd149 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -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; pendingSpotify?: SongMeta[] }; +/** 해석 결과. */ +type Resolved = { tracks: Track[]; playlist: boolean; flags: Set }; + +/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */ +const MatchCacheSize = 500; export class LavalinkManager { public shoukaku: Shoukaku; private players: Map = new Map(); + private matchCache: Map> = 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 { + 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(() => {}); diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 0f34519..a6c38fe 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -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(); diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index f9bdd40..b43627e 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -2,8 +2,14 @@ import "dotenv/config"; import { Logger } from "../Logger"; import { SongItem } from "../../types/Track"; +import type { Track } from "shoukaku"; import type { SongMeta } from "../music/TrackMatch"; +/** 스포티파이 재생목록/앨범의 곡 하나(임베드 페이지 기준). */ +export type SpotifyEntry = SongMeta & { id: string }; + +const LAZY_PREFIX = "spotify-lazy:"; + const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? ""; const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; @@ -142,11 +148,12 @@ export const Spotify = { }, /** - * 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다. - * 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라 - * (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다. + * 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 가져온다. + * 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이고, + * Lavalink(LavaSrc) partner API는 되지만 100곡에 7~8초가 걸린다. 공개 임베드 페이지는 한 번(~0.4초)에 + * 받을 수 있어 이걸 쓴다. 최대 100곡까지 제공된다. 실패하면 tracks가 빈 배열. */ - async getCollectionTracks(kind: "playlist" | "album", id: string): Promise { + async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<{ tracks: SpotifyEntry[]; cover?: string }> { try { const response = await fetch(`https://open.spotify.com/embed/${kind}/${encodeURIComponent(id)}`, { headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" }, @@ -154,28 +161,66 @@ export const Spotify = { }); if (!response.ok) { Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`); - return []; + return { tracks: [] }; } const html = await response.text(); const m = html.match(/