feat(bot): 스포티파이 재생목록을 곡 정보만 즉시 대기열에 넣고 재생 직전에 유튜브로 변환
- 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:<id>)을 바로 추가
(기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초)
- GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치)
- 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록
- 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체
- '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열
- queue_set: JSON으로 깨진 _resolved({}) 제거
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,14 @@
|
||||
import "dotenv/config";
|
||||
import { Logger } from "../Logger";
|
||||
import { SongItem } from "../../types/Track";
|
||||
import type { Track } from "shoukaku";
|
||||
import type { SongMeta } from "../music/TrackMatch";
|
||||
|
||||
/** 스포티파이 재생목록/앨범의 곡 하나(임베드 페이지 기준). */
|
||||
export type SpotifyEntry = SongMeta & { id: string };
|
||||
|
||||
const LAZY_PREFIX = "spotify-lazy:";
|
||||
|
||||
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
|
||||
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
|
||||
|
||||
@@ -142,11 +148,12 @@ export const Spotify = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다.
|
||||
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라
|
||||
* (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다.
|
||||
* 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 가져온다.
|
||||
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이고,
|
||||
* Lavalink(LavaSrc) partner API는 되지만 100곡에 7~8초가 걸린다. 공개 임베드 페이지는 한 번(~0.4초)에
|
||||
* 받을 수 있어 이걸 쓴다. 최대 100곡까지 제공된다. 실패하면 tracks가 빈 배열.
|
||||
*/
|
||||
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<SongMeta[]> {
|
||||
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<{ tracks: SpotifyEntry[]; cover?: string }> {
|
||||
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" },
|
||||
@@ -154,28 +161,66 @@ export const Spotify = {
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`);
|
||||
return [];
|
||||
return { tracks: [] };
|
||||
}
|
||||
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;
|
||||
const entity = m ? JSON.parse(m[1])?.props?.pageProps?.state?.data?.entity : null;
|
||||
const list = entity?.trackList;
|
||||
if (!Array.isArray(list)) {
|
||||
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
|
||||
return [];
|
||||
return { tracks: [] };
|
||||
}
|
||||
return list
|
||||
.filter((t: any) => t?.title && (t.entityType ?? "track") === "track")
|
||||
.map((t: any) => ({
|
||||
const tracks = list
|
||||
.map((t: any) => ({ t, id: String(t?.uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1] }))
|
||||
.filter(({ t, id }: any) => id && t?.title)
|
||||
.map(({ t, id }: any) => ({
|
||||
id,
|
||||
title: String(t.title),
|
||||
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
|
||||
durationMs: Number(t.duration) || 0,
|
||||
}));
|
||||
// 앨범은 visualIdentity.image(크기별 목록), 플레이리스트는 coverArt.sources 에 커버가 있다
|
||||
const images: any[] = Array.isArray(entity?.visualIdentity?.image) ? entity.visualIdentity.image : [];
|
||||
const largest = images.reduce((best, img) => (!best || (img?.maxWidth ?? 0) > (best?.maxWidth ?? 0) ? img : best), null);
|
||||
const cover = largest?.url ?? entity?.coverArt?.sources?.[0]?.url;
|
||||
return { tracks, cover: typeof cover === "string" ? cover : undefined };
|
||||
} catch (err) {
|
||||
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${err}`);
|
||||
return [];
|
||||
return { tracks: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 재생목록 곡을 '아직 유튜브로 변환하지 않은' 대기열 항목으로 만든다.
|
||||
* encoded는 Lavalink 트랙이 아니라 표식(`spotify-lazy:<id>`)이고, 재생 직전에
|
||||
* GuildPlayer.resolvePlayable 이 유튜브 원곡을 찾아 실제 재생용 트랙으로 바꾼다.
|
||||
* coverIsPlaylist: 커버가 곡 앨범아트가 아니라 재생목록 커버라서, 변환할 때 곡 이미지로 바꿔도 되는지.
|
||||
*/
|
||||
toLazyTrack(entry: SpotifyEntry, cover: string | undefined, coverIsPlaylist: boolean): Track {
|
||||
return {
|
||||
encoded: `${LAZY_PREFIX}${entry.id}`,
|
||||
info: {
|
||||
identifier: entry.id,
|
||||
isSeekable: true,
|
||||
author: entry.artist,
|
||||
length: entry.durationMs,
|
||||
isStream: false,
|
||||
position: 0,
|
||||
title: entry.title,
|
||||
uri: `https://open.spotify.com/track/${entry.id}`,
|
||||
artworkUrl: cover,
|
||||
sourceName: "spotify",
|
||||
},
|
||||
pluginInfo: { lazy: true, coverIsPlaylist },
|
||||
};
|
||||
},
|
||||
|
||||
/** toLazyTrack 으로 만든(아직 변환 전인) 항목인지. 이런 항목의 encoded는 Lavalink에 넘기면 안 된다. */
|
||||
isLazyTrack(track: Track): boolean {
|
||||
return typeof track.encoded === "string" && track.encoded.startsWith(LAZY_PREFIX);
|
||||
},
|
||||
|
||||
/** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
|
||||
async getTrackById(id: string): Promise<SongMeta | null> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user