fix(bot): 스포티파이 재생목록 '노래를 찾을수 없습니다'·엉뚱한 곡 재생 수정
- 재생목록/앨범: Spotify Web API 정책 변경으로 LavaSrc가 403으로 실패 → 봇이 공개 임베드에서 곡 목록(최대 100곡)을 받아 곡마다 유튜브 원곡을 매칭. 첫 곡 즉시 재생, 나머지는 백그라운드로 추가 - 단일 곡: 유튜브뮤직 검색이 팟캐스트·분석 영상을 줘 엉뚱한 곡이 걸리던 문제 → 제목·아티스트· 재생시간(±수 초)·2차영상 감점으로 유튜브 검색 결과 중 원곡 오디오를 선택(TrackMatch) - open.spotify.com/intl-ko/... 링크도 인식 - 유튜브뮤직 검색 결과에서 팟캐스트 에피소드·재생목록 등 비곡 항목 제외 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>; 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<string> } | null> {
|
||||
public async resolveQuery(query: string): Promise<Resolved | null> {
|
||||
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<string> },
|
||||
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<Track | null> {
|
||||
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 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
|
||||
|
||||
Reference in New Issue
Block a user