From 231dc6d71657623925da9ed2d32cb4653ed1615b Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Fri, 2 Oct 2026 23:14:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(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=20'?= =?UTF-8?q?=EB=85=B8=EB=9E=98=EB=A5=BC=20=EC=B0=BE=EC=9D=84=EC=88=98=20?= =?UTF-8?q?=EC=97=86=EC=8A=B5=EB=8B=88=EB=8B=A4'=C2=B7=EC=97=89=EB=9A=B1?= =?UTF-8?q?=ED=95=9C=20=EA=B3=A1=20=EC=9E=AC=EC=83=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재생목록/앨범: Spotify Web API 정책 변경으로 LavaSrc가 403으로 실패 → 봇이 공개 임베드에서 곡 목록(최대 100곡)을 받아 곡마다 유튜브 원곡을 매칭. 첫 곡 즉시 재생, 나머지는 백그라운드로 추가 - 단일 곡: 유튜브뮤직 검색이 팟캐스트·분석 영상을 줘 엉뚱한 곡이 걸리던 문제 → 제목·아티스트· 재생시간(±수 초)·2차영상 감점으로 유튜브 검색 결과 중 원곡 오디오를 선택(TrackMatch) - open.spotify.com/intl-ko/... 링크도 인식 - 유튜브뮤직 검색 결과에서 팟캐스트 에피소드·재생목록 등 비곡 항목 제외 Co-Authored-By: Claude Opus 5.5 --- bot/src/classes/LavalinkManager.ts | 75 +++++++++++++++++++++++++++--- bot/src/utils/api/Spotify.ts | 51 ++++++++++++++++++-- bot/src/utils/api/YoutubeMusic.ts | 7 ++- bot/src/utils/music/TrackMatch.ts | 45 ++++++++++++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 bot/src/utils/music/TrackMatch.ts diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index c840da5..04f3f82 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -9,6 +9,10 @@ import { parseLink } from "../utils/music/Url"; import { shuffle } from "../utils/Shuffle"; 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[] }; export class LavalinkManager { public shoukaku: Shoukaku; @@ -109,10 +113,23 @@ export class LavalinkManager { * 오디오 URL이 이미 준비되도록 한다. * 못 찾으면 null. */ - public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set } | null> { + public async resolveQuery(query: string): Promise { 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으로 실패하므로 봇이 곡 목록을 직접 받아 + // 곡마다 유튜브 원곡을 매칭한다. 첫 곡만 지금 매칭해 바로 재생하고, 나머지는 큐 추가 후 백그라운드에서 이어 붙인다. + 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) }; + } // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // ('-p' 플래그를 붙이면 스포티파이 우선 검색) @@ -133,14 +150,19 @@ export class LavalinkManager { if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음 } // 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도). - // 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로 - // 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선). + // 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트+길이)로 + // 유튜브 검색 결과 중 같은 곡의 원곡 오디오를 골라 대체한다. + // (유튜브뮤직 검색은 현재 노래 대신 팟캐스트·분석 영상을 주는 경우가 많아 엉뚱한 곡이 걸렸다) const spotifyTrackId = Spotify.parseTrackId(searchText); if (spotifyTrackId) { const meta = await Spotify.getTrackById(spotifyTrackId); if (meta) { - const q = `${meta.title} ${meta.artist}`.trim(); - searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`; + const matched = await this.findYoutubeMatch(meta); + if (matched) { + this.prewarmTrack(matched); + return { tracks: [matched], playlist: false, flags }; + } + searchText = `ytsearch:${meta.title} ${meta.artist}`.trim(); } } if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic"; @@ -171,16 +193,57 @@ export class LavalinkManager { */ public async addResolved( player: GuildPlayer, - resolved: { tracks: Track[]; playlist: boolean; flags: Set }, + resolved: Resolved, 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)}`)); + } return; } await player.addTrack(resolved.tracks[0], userId); } + /** + * 스포티파이 곡 메타데이터로 유튜브를 검색해 같은 곡의 원곡 오디오 트랙을 고른다. 못 찾으면 null. + */ + public async findYoutubeMatch(meta: SongMeta): Promise { + const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); + if (!node) return null; + const artist = meta.artist.split(",")[0].trim(); + try { + const result = await node.rest.resolve(`ytsearch:${artist} ${meta.title}`.trim()); + const candidates = result?.loadType === LoadType.SEARCH ? result.data : []; + const picked = pickBestMatch(candidates, meta); + if (!picked) Logger.warn(`[LavalinkManager] 유튜브에서 같은 곡을 찾지 못함: "${meta.artist} - ${meta.title}"`); + return picked; + } catch (e) { + Logger.warn(`[LavalinkManager] 유튜브 매칭 검색 실패: "${meta.artist} - ${meta.title}" ${String(e)}`); + return null; + } + } + + /** 스포티파이 재생목록의 나머지 곡을 순서대로 매칭해 큐에 이어 붙인다. 정지/퇴장하면 중단. */ + 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}곡 추가 완료`); + } + /** * 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget). * GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다. diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index 87408a7..f9bdd40 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -2,6 +2,7 @@ import "dotenv/config"; import { Logger } from "../Logger"; import { SongItem } from "../../types/Track"; +import type { SongMeta } from "../music/TrackMatch"; const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? ""; const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; @@ -128,14 +129,55 @@ export const Spotify = { return track?.url ?? null; }, - /** open.spotify.com/track/ 또는 spotify:track: 에서 트랙 id를 추출. 아니면 null. */ + /** open.spotify.com/(intl-xx/)track/ 또는 spotify:track: 에서 트랙 id를 추출. 아니면 null. */ parseTrackId(text: string): string | null { - const m = text.trim().match(/(?:open\.spotify\.com\/track\/|spotify:track:)([A-Za-z0-9]+)/); + const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?track\/|spotify:track:)([A-Za-z0-9]+)/); return m ? m[1] : null; }, - /** 트랙 id로 제목/아티스트 메타데이터 조회 (유튜브뮤직 오디오 검색어 생성용). */ - async getTrackById(id: string): Promise<{ title: string; artist: string } | null> { + /** 스포티파이 플레이리스트/앨범 링크면 종류와 id를 추출. 아니면 null. */ + parseCollection(text: string): { kind: "playlist" | "album"; id: string } | null { + const m = text.trim().match(/(?:open\.spotify\.com\/(?:intl-[\w-]+\/)?|spotify:)(playlist|album)[\/:]([A-Za-z0-9]+)/); + return m ? { kind: m[1] as "playlist" | "album", id: m[2] } : null; + }, + + /** + * 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다. + * 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라 + * (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다. + */ + async getCollectionTracks(kind: "playlist" | "album", id: string): Promise { + 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" }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`); + return []; + } + const html = await response.text(); + const m = html.match(/