From 5756f4e10a09f0a5757583b5f36f26d88f5aebdd Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Wed, 27 May 2026 20:33:28 +0900 Subject: [PATCH] =?UTF-8?q?bot:=20Spotify=20=EC=9E=90=EB=8F=99=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=ED=8F=B4=EB=B0=B1=20(sprec=20EMPTY=20=E2=86=92=20Y?= =?UTF-8?q?ouTube=20RD=20=EB=AF=B9=EC=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sprec이 EMPTY/ERROR/빈 PLAYLIST면 곡 메타데이터(artist, title)로 ytsearch 후 첫 결과의 videoId로 RD 믹스를 가져와 자동재생 큐 채움 - 리뷰어 확인: ytmsearch는 현재 Lavalink에서 EMPTY를 반환해서 ytsearch 사용 - sprec / 검색 / 믹스 resolve 각 단계에 try/catch 추가 (한 단계 실패 시 다음 단계로 진행하지 않고 폴백 또는 end로 안전하게 빠짐) - 진단 로깅: sprec loadType, 폴백 사유, 검색 매칭 videoId, 최종 후보 곡수 --- bot/src/classes/GuildPlayer.ts | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index 67fa9d7..37cf2ce 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -275,12 +275,45 @@ export class GuildPlayer { if (!this.lastPlayedTrack) return; const source = this.lastPlayedTrack.info.sourceName; const trackId = this.lastPlayedTrack.info.identifier; + const title = this.lastPlayedTrack.info.title; + const author = this.lastPlayedTrack.info.author; let result; + let usedYtFallback = false; + 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으로 폴백합니다.`); + } + result = await this.youtubeMixFromSpotifyTrack(author, title); + usedYtFallback = true; + } } 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[] = []; if (result?.loadType === LoadType.PLAYLIST) { tracks = result.data.tracks; @@ -289,6 +322,7 @@ export class GuildPlayer { } else if (result?.loadType === LoadType.TRACK) { tracks = [ result.data ]; } + Logger.info(`[autoPlay] 자동재생 후보 ${tracks.length}곡${usedYtFallback ? " (YouTube 폴백)" : ""}`); if (tracks.length === 0) { this.end(); return; @@ -301,6 +335,39 @@ export class GuildPlayer { this.addTracks(tracks, "자동재생"); } + /** + * Spotify 트랙 메타데이터로 YouTube 검색 후 매칭된 곡의 RD 믹스를 가져옴. + * (현재 Lavalink에서 `ytmsearch:`는 EMPTY를 자주 반환하므로 `ytsearch:` 사용) + * 각 단계가 실패하면 다음 단계로 진행하지 않고 undefined 반환. + */ + 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 ytId = searchResult.data[0].info.identifier; + Logger.info(`[autoPlay] YouTube 폴백 매칭: videoId=${ytId}`); + try { + return await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${ytId}&list=RD${ytId}`); + } catch (err) { + Logger.warn(`[autoPlay] YouTube 폴백 RD 믹스 resolve throw: ${String(err)}`); + return undefined; + } + } + private clearAllTimers() { if (this.errorTimer !== undefined) { clearTimeout(this.errorTimer);