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:
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
bot/src/utils/music/TrackMatch.ts
Normal file
45
bot/src/utils/music/TrackMatch.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user