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:
2026-10-03 18:02:28 +09:00
parent b55dde52ca
commit c75e42e10e
5 changed files with 147 additions and 62 deletions

View File

@@ -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 {