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

@@ -142,6 +142,10 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가. - 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가.
- 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글. - 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
- 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트. - 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
- 스포티파이 재생목록/앨범: 공개 임베드 페이지에서 곡 목록(최대 100곡)을 받아 **곡 정보만** 곧바로 대기열에 넣는다(~0.4초).
유튜브 원곡 매칭은 곡마다 **재생 직전**에 한다(`GuildPlayer.resolvePlayable` → `LavalinkManager.matchSpotifyTrack`).
다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다. 임베드 조회가 실패하면 Lavalink(LavaSrc)로 불러온다.
스포티파이 음원 자체를 직접 재생하지는 않는다(LavaSrc도 항상 다른 음원으로 미러링하며, 직접 재생은 Premium 계정 + librespot이 필요).
- 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백). - 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).
### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임) ### 텍스트 검색 순서 & 플래그 (검색어 뒤에 `-x` 형태로 붙임)

View File

@@ -9,6 +9,7 @@ import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel"; import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config"; import { Config } from "../utils/Config";
import { Spotify } from "../utils/api/Spotify";
const DelayAfterErrMs = 1000 * 5; const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10; const idleEndTime = 1000 * 60 * 10;
@@ -78,9 +79,11 @@ export class GuildPlayer {
if (this.isDead) return; if (this.isDead) return;
if (this.queue[0]?.encoded === cur.encoded) { if (this.queue[0]?.encoded === cur.encoded) {
const reEncoded = await this.resolvePlayable(cur); const reEncoded = await this.resolvePlayable(cur);
if (reEncoded) {
await this.player.playTrack({ track: { encoded: reEncoded } }); await this.player.playTrack({ track: { encoded: reEncoded } });
return; return;
} }
}
} else { } else {
Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`); Logger.error(`[GuildPlayer] "${this.queue[0].info.title}" ${MaxLoadFailRetry}회 재시도 실패, 다음 곡으로 넘어갑니다.`);
} }
@@ -194,8 +197,8 @@ export class GuildPlayer {
await this.player.setGlobalVolume(savedVolume || 50); await this.player.setGlobalVolume(savedVolume || 50);
// 곡이 있었으면 이어서 재생 // 곡이 있었으면 이어서 재생
if (currentTrack) { const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
const reEncoded = await this.resolvePlayable(currentTrack); if (currentTrack && reEncoded) {
await this.player.playTrack({ await this.player.playTrack({
track: { encoded: reEncoded }, track: { encoded: reEncoded },
position: currentPosition, position: currentPosition,
@@ -257,27 +260,45 @@ export class GuildPlayer {
} }
} }
/** 유튜브(및 스포티파이 미러) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생. /** 유튜브(및 스포티파이) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
* youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백. */ * youtube-source가 집 IP의 SABR 차단으로 실패하는 문제를 우회한다. 실패 시 원본 encoded로 폴백.
* 스포티파이 곡은 여기서(재생 직전에) 유튜브 원곡을 찾아 그 오디오를 쓴다. 재생목록 항목은 대기열에
* 곡 정보만 들어 있어서, 원곡을 못 찾으면 재생할 수 없음을 뜻하는 빈 문자열을 돌려준다. */
private async resolvePlayable(track: QueueTrack): Promise<string> { private async resolvePlayable(track: QueueTrack): Promise<string> {
let source: Track = track;
let fallback = Spotify.isLazyTrack(track) ? "" : track.encoded;
try { try {
const info: any = (track as any).info ?? {}; if (track.info.sourceName === "spotify") {
const matched = await lavalinkManager.matchSpotifyTrack(track);
if (matched) {
source = matched;
fallback = matched.encoded;
// 재생목록 커버를 임시로 쓰던 곡은 찾은 곡의 이미지로 바꾼다(앨범은 앨범 커버가 맞으므로 유지)
if (!track.info.artworkUrl || (track.pluginInfo as any)?.coverIsPlaylist) {
track.info.artworkUrl = matched.info.artworkUrl ?? track.info.artworkUrl;
}
} else if (!fallback) {
Logger.warn(`[GuildPlayer] 유튜브에서 같은 곡을 찾지 못해 건너뜁니다: "${track.info.author} - ${track.info.title}"`);
return "";
}
}
const info: any = (source as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded; if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return fallback;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim(); const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return track.encoded; if (!target) return fallback;
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }); const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
if (!r.ok) return track.encoded; if (!r.ok) return fallback;
const data = (await r.json()) as { url?: string }; const data = (await r.json()) as { url?: string };
if (!data.url) return track.encoded; if (!data.url) return fallback;
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes); const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
if (!node) return track.encoded; if (!node) return fallback;
const res = await node.rest.resolve(data.url); const res = await node.rest.resolve(data.url);
if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string; if (res?.loadType === LoadType.TRACK) return (res.data as any).encoded as string;
return track.encoded; return fallback;
} catch (e) { } catch (e) {
Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`); Logger.warn(`[GuildPlayer] yt-dlp 리졸브 실패, 원본 소스로 재생: ${String(e)}`);
return track.encoded; return fallback;
} }
} }
@@ -287,7 +308,7 @@ export class GuildPlayer {
track._resolved = this.resolvePlayable(track).catch((e) => { track._resolved = this.resolvePlayable(track).catch((e) => {
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`); Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도 track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
return track.encoded; return Spotify.isLazyTrack(track) ? "" : track.encoded;
}); });
} }
@@ -297,12 +318,19 @@ export class GuildPlayer {
return track._resolved!; return track._resolved!;
} }
public async playNext() { public async playNext(): Promise<void> {
const track = this.queue[0]; const track = this.queue[0];
if (!track) return this.end(); if (!track) return this.end();
const tResolve = Date.now(); const tResolve = Date.now();
const encoded = await this.getResolved(track); const encoded = await this.getResolved(track);
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`); Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
if (!encoded) {
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
if (this.isDead || this.queue[0] !== track) return;
this.queue.shift();
if (this.queue.length === 0 && this.isRecommend && this.canRecommend && this.lastPlayedTrack) return this.autoPlay();
return this.playNext();
}
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거 // 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]); this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } }); await this.player.playTrack({ track: { encoded } });

View File

@@ -11,12 +11,16 @@ import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch"; import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
/** 해석 결과. pendingSpotify: 스포티파이 재생목록에서 아직 유튜브로 매칭하지 않은 나머지 곡(큐 추가 후 백그라운드 매칭). */ /** 해석 결과. */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; pendingSpotify?: SongMeta[] }; type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string> };
/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */
const MatchCacheSize = 500;
export class LavalinkManager { export class LavalinkManager {
public shoukaku: Shoukaku; public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map(); private players: Map<string, GuildPlayer> = new Map();
private matchCache: Map<string, Promise<Track | null>> = new Map();
constructor(readonly client: Client) { constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{ this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
@@ -117,18 +121,18 @@ export class LavalinkManager {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim()); const { isUrl, text, flags } = parseLink(query.trim());
// 스포티파이 플레이리스트/앨범: Lavalink(LavaSrc)는 Spotify API 403으로 실패하므로 봇이 곡 목록을 직접 받아 // 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
// 곡마다 유튜브 원곡을 매칭한다. 첫 곡만 지금 매칭해 바로 재생하고, 나머지는 큐 추가 후 백그라운드에서 이어 붙인다. // 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 그래서 100곡짜리도 곡 목록을
// 받는 시간(~0.4초)만 기다리면 된다. 임베드 조회가 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
const collection = isUrl ? Spotify.parseCollection(text) : null; const collection = isUrl ? Spotify.parseCollection(text) : null;
if (collection) { if (collection) {
const entries = await Spotify.getCollectionTracks(collection.kind, collection.id); const { tracks: entries, cover } = await Spotify.getCollectionTracks(collection.kind, collection.id);
if (flags.has('s')) shuffle(entries); if (entries.length > 0) {
let first: Track | null = null; const tracks = entries.map((e) => Spotify.toLazyTrack(e, cover, collection.kind === "playlist"));
let i = 0; if (flags.has('s')) shuffle(tracks);
while (!first && i < entries.length && i < 5) first = await this.findYoutubeMatch(entries[i++]); this.prewarmTrack(tracks[0]);
if (!first) return null; return { tracks, playlist: true, flags };
this.prewarmTrack(first); }
return { tracks: [first], playlist: true, flags, pendingSpotify: entries.slice(i) };
} }
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
@@ -177,6 +181,7 @@ export class LavalinkManager {
if (result.loadType === LoadType.PLAYLIST) { if (result.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks; tracks = result.data.tracks;
playlist = true; playlist = true;
if (flags.has('s')) shuffle(tracks);
} else if (result.loadType === LoadType.TRACK) { } else if (result.loadType === LoadType.TRACK) {
tracks = [result.data]; tracks = [result.data];
} else if (result.loadType === LoadType.SEARCH) { } else if (result.loadType === LoadType.SEARCH) {
@@ -197,11 +202,8 @@ export class LavalinkManager {
userId: string, userId: string,
) { ) {
if (resolved.playlist) { if (resolved.playlist) {
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId); // '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
if (resolved.pendingSpotify?.length) { await player.addTracks(resolved.tracks, userId);
this.appendSpotifyEntries(player, resolved.pendingSpotify, userId)
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 추가 실패: ${String(e)}`));
}
return; return;
} }
await player.addTrack(resolved.tracks[0], userId); await player.addTrack(resolved.tracks[0], userId);
@@ -226,22 +228,20 @@ export class LavalinkManager {
} }
} }
/** 스포티파이 재생목록의 나머지 곡을 순서대로 매칭해 큐에 이어 붙인다. 정지/퇴장하면 중단. */ /**
private async appendSpotifyEntries(player: GuildPlayer, entries: SongMeta[], userId: string) { * 스포티파이 곡(재생목록 항목·자동재생 추천곡)을 재생할 유튜브 원곡 트랙으로 바꾼다. 못 찾으면 null.
const chunkSize = 5; * 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다.
let added = 0; */
for (let i = 0; i < entries.length; i += chunkSize) { public matchSpotifyTrack(track: Track): Promise<Track | null> {
const found = await Promise.all(entries.slice(i, i + chunkSize).map((e) => this.findYoutubeMatch(e))); const key = track.info.identifier;
// 그 사이 정지(큐 비움)했거나 봇이 나갔으면 더 붙이지 않는다 const cached = this.matchCache.get(key);
if (this.getPlayer(player.guild.id) !== player || player.queue.length === 0) { if (cached) return cached;
Logger.info(`[LavalinkManager] 스포티파이 재생목록 추가 중단 (정지/퇴장), ${added}곡 추가됨`); const pending = this.findYoutubeMatch({ title: track.info.title, artist: track.info.author, durationMs: track.info.length });
return; this.matchCache.set(key, pending);
} // 못 찾은 결과는 남기지 않는다(다음에 다시 시도)
const tracks = found.filter((t): t is Track => !!t); pending.then((t) => { if (!t && this.matchCache.get(key) === pending) this.matchCache.delete(key); }, () => this.matchCache.delete(key));
if (tracks.length > 0) await player.addTracks(tracks, userId); if (this.matchCache.size > MatchCacheSize) this.matchCache.delete(this.matchCache.keys().next().value!);
added += tracks.length; return pending;
}
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}/${entries.length}곡 추가 완료`);
} }
/** /**
@@ -252,7 +252,12 @@ export class LavalinkManager {
try { try {
const info: any = (track as any).info ?? {}; const info: any = (track as any).info ?? {};
const src = info.sourceName; const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return; // 스포티파이 곡은 유튜브 원곡 매칭부터 예열하고, 찾으면 그 유튜브 주소를 리졸브해 둔다.
if (src === "spotify") {
this.matchSpotifyTrack(track).then((m) => { if (m) this.prewarmTrack(m); }).catch(() => {});
return;
}
if (src !== "youtube" && src !== "youtubemusic") return;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim(); const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return; if (!target) return;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {}); fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});

View File

@@ -172,7 +172,10 @@ class RedisClientClass {
context.player.queue.length = 0; context.player.queue.length = 0;
if (nowTrack) context.player.queue.push(nowTrack); if (nowTrack) context.player.queue.push(nowTrack);
for (const rawTrack of data.newQueue) { for (const rawTrack of data.newQueue) {
if (rawTrack.encoded) context.player.queue.push(rawTrack); if (!rawTrack?.encoded) continue;
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
const { _resolved, ...track } = rawTrack;
context.player.queue.push(track);
} }
await this.reply(resultKey, 60, JSON.stringify({ success: true })); await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg(); context.player.setMsg();

View File

@@ -2,8 +2,14 @@
import "dotenv/config"; import "dotenv/config";
import { Logger } from "../Logger"; import { Logger } from "../Logger";
import { SongItem } from "../../types/Track"; import { SongItem } from "../../types/Track";
import type { Track } from "shoukaku";
import type { SongMeta } from "../music/TrackMatch"; 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_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? ""; const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
@@ -142,11 +148,12 @@ export const Spotify = {
}, },
/** /**
* 플레이리스트/앨범의 곡 목록(제목·아티스트·길이)을 가져온다. * 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 가져온다.
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이라 * 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이고,
* (Lavalink LavaSrc도 같은 이유로 실패), 공개 임베드 페이지의 데이터를 쓴다. 최대 100곡까지 제공된다. * 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 { try {
const response = await fetch(`https://open.spotify.com/embed/${kind}/${encodeURIComponent(id)}`, { 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" }, 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) { if (!response.ok) {
Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`); Logger.error(`스포티파이 ${kind} 임베드 조회 에러 (${response.status})`);
return []; return { tracks: [] };
} }
const html = await response.text(); const html = await response.text();
const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/); 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)) { if (!Array.isArray(list)) {
Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`); Logger.error(`스포티파이 ${kind} 임베드에서 곡 목록을 찾지 못했습니다: ${id}`);
return []; return { tracks: [] };
} }
return list const tracks = list
.filter((t: any) => t?.title && (t.entityType ?? "track") === "track") .map((t: any) => ({ t, id: String(t?.uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1] }))
.map((t: any) => ({ .filter(({ t, id }: any) => id && t?.title)
.map(({ t, id }: any) => ({
id,
title: String(t.title), title: String(t.title),
artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(), artist: String(t.subtitle ?? "").replace(/\u00a0/g, " ").trim(),
durationMs: Number(t.duration) || 0, 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) { } catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록 조회 실패: ${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로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */ /** 트랙 id로 제목/아티스트/길이 메타데이터 조회 (유튜브 원곡 오디오 매칭용). */
async getTrackById(id: string): Promise<SongMeta | null> { async getTrackById(id: string): Promise<SongMeta | null> {
try { try {