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:
2026-10-02 23:14:19 +09:00
parent 750bc0c9c8
commit 231dc6d716
4 changed files with 167 additions and 11 deletions

View File

@@ -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 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.

View File

@@ -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/<id> 또는 spotify:track:<id> 에서 트랙 id를 추출. 아니면 null. */
/** open.spotify.com/(intl-xx/)track/<id> 또는 spotify:track:<id> 에서 트랙 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<SongMeta[]> {
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(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
const list = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity?.trackList : null;
if (!Array.isArray(list)) {
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
return [];
}
return list
.filter((t: any) => t?.title && (t.entityType ?? "track") === "track")
.map((t: any) => ({
title: String(t.title),
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
durationMs: Number(t.duration) || 0,
}));
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
return [];
}
},
/** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
async getTrackById(id: string): Promise<SongMeta | null> {
try {
const token = await this.getToken();
if (!token) return null;
@@ -151,6 +193,7 @@ export const Spotify = {
return {
title: track.name,
artist: (track.artists ?? []).map((a) => a.name).join(", "),
durationMs: track.duration_ms ?? 0,
};
} catch (err) {
Logger.error(`스포티파이 트랙 조회 실패: ${err}`);

View File

@@ -11,6 +11,9 @@ const JUNK_RE = /(\bMR\b|inst(?:rumental)?|instrumental|karaoke|노래방|\bcove
export const ORIGIN = "https://music.youtube.com";
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
const searchCache = new Map<string, string>();
// 검색 결과 행의 종류 표기(부제 첫 칸). 노래/동영상이 아닌 팟캐스트 에피소드·재생목록 등은 곡 후보에서 뺀다.
// (현재 YTM 검색이 노래 대신 '분석/루프' 같은 팟캐스트 에피소드를 상단에 주는 경우가 많다)
const NON_SONG_TYPES = new Set(["에피소드", "팟캐스트", "재생목록", "아티스트", "앨범", "싱글", "EP", "프로필", "Episode", "Podcast", "Playlist", "Artist", "Album", "Single", "Profile"]);
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
const defaultCookies: Cookies[] = [
@@ -131,6 +134,7 @@ export const YoutubeMusic = {
// 1. 최상단 'Top Result' 카드 파싱
if (section.musicCardShelfRenderer) {
const card = section.musicCardShelfRenderer;
const cardType = card.subtitle?.runs?.[0]?.text;
const title = card.title?.runs?.[0]?.text;
const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId;
@@ -148,7 +152,7 @@ export const YoutubeMusic = {
const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || [];
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
if (videoId && title) {
if (videoId && title && !NON_SONG_TYPES.has(cardType)) {
results.push({
videoId,
url: `https://music.youtube.com/watch?v=${videoId}`,
@@ -173,6 +177,7 @@ export const YoutubeMusic = {
let artist = "Unknown Artist";
const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
if (NON_SONG_TYPES.has(subtitleRuns[0]?.text)) continue;
if (subtitleRuns.length >= 3) {
artist = subtitleRuns[2]?.text || artist;

View File

@@ -0,0 +1,45 @@
import type { Track } from "shoukaku";
/** 스포티파이 곡 메타데이터(제목/아티스트/길이). 유튜브에서 같은 곡을 고르는 기준. */
export type SongMeta = { title: string; artist: string; durationMs: number };
// 원곡 오디오가 아닌 2차 영상(무대·직캠·리액션·분석·커버·루프 등). 원곡 제목 자체에 있는 단어는 감점하지 않는다.
const NOT_ORIGINAL_RE = /\b(cover|karaoke|inst|instrumental|mr|remix|sped up|slowed|nightcore|8d|live|reaction|dance practice|choreography|mirrored|fancam|performance|stage|teaser|behind|making|analysis|loop|1 ?hour|1hr|hours|tutorial|full album|playlist|medley|mashup|inkigayo|music ?core|music ?bank|m ?countdown|it's live)\b|노래방|커버|리액션|반응|분석|해석|직캠|무대|방송|인기가요|음악중심|엠카운트다운|뮤직뱅크|라이브|비하인드|연습|교차편집|모음|메들리|\d+\s?분|\d+\s?시간/gi;
const norm = (s: string) => s
.normalize("NFKC")
.toLocaleLowerCase()
.replace(/[’‘`´]/g, "'")
.replace(/[^\p{L}\p{N}']+/gu, " ")
.trim();
/**
* 유튜브 검색 결과 중 스포티파이 곡과 '같은 곡의 원곡 오디오'로 보이는 후보를 고른다.
* 기준: 제목 포함 여부, 아티스트 포함 여부, 재생시간 일치(스포티파이 길이와 ±수 초), 2차 영상 감점.
* 제목이 안 맞는 후보는 재생시간·아티스트가 정확히 맞을 때만 인정한다. 적당한 후보가 없으면 null.
*/
export function pickBestMatch(candidates: Track[], want: SongMeta): Track | null {
const wantTitle = norm(want.title);
const titleCore = norm(want.title.replace(/\s*[\(\[][^\)\]]*[\)\]]/g, "")) || wantTitle;
const coreTokens = titleCore.split(" ").filter(Boolean);
const artists = want.artist.split(",").map(norm).filter(Boolean);
let best: { track: Track; score: number } | null = null;
for (const track of candidates) {
const info = track.info;
if (!info || info.isStream) continue;
const hay = norm(`${info.title} ${info.author}`);
const titleHit = hay.includes(titleCore) ? 3 : coreTokens.every((t) => hay.includes(t)) ? 2 : 0;
const artistHit = artists.some((a) => hay.includes(a)) ? 1 : 0;
const junk = (info.title.match(NOT_ORIGINAL_RE) ?? []).some((w) => !wantTitle.includes(norm(w)));
const diffSec = want.durationMs > 0 ? Math.abs(info.length - want.durationMs) / 1000 : -1;
const durScore = diffSec < 0 ? 0 : diffSec <= 2 ? 3 : diffSec <= 5 ? 2 : diffSec <= 12 ? 1 : diffSec <= 40 ? 0 : -3;
const audioBonus = /\baudio\b|오디오/i.test(info.title) || / - topic$/i.test(info.author) ? 1 : 0;
// 제목이 안 맞으면 길이(±2초)와 아티스트가 모두 맞는 경우만 같은 곡으로 인정
if (titleHit === 0 && !(diffSec >= 0 && diffSec <= 2 && artistHit)) continue;
const score = titleHit + artistHit + durScore + audioBonus - (junk ? 4 : 0);
if (!best || score > best.score) best = { track, score };
}
return best && best.score > 0 ? best.track : null;
}