feat: 스포티파이 재생목록 최대 50,000곡까지 불러오기

- 봇: partner API(fetchPlaylist/getAlbum, 토크너 익명 토큰)로 첫 100곡을 받아 바로 재생,
  나머지는 1000곡씩(동시 3페이지) 백그라운드로 이어 붙임. 곡마다 앨범 커버 포함.
  실패 시 임베드 페이지(100곡) → Lavalink 순으로 폴백
- 정지·끝·모두 지우기 시 queueEpoch 증가 → 이어 받기 중단. '-s'는 페이지마다 재생목록 곡끼리 재섞기
- 토크너 토큰 캐시를 정식 API 토큰 캐시와 분리
- queue_list: 앞 200곡 + total/totalMs만 전송, queue_set: windowSize 부분만 교체·clearAll 지원
- 대시보드: 곡 수·총 길이는 전체 기준, '외 N곡' 표시, 모두 지우기는 clearAll

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:11:31 +09:00
parent c75e42e10e
commit 63456d8101
10 changed files with 296 additions and 47 deletions

View File

@@ -142,9 +142,15 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
- 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가.
- 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
- 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
- 스포티파이 재생목록/앨범: 공개 임베드 페이지에서 곡 목록(최대 100곡)을 받아 **곡 정보만** 곧바로 대기열에 넣는다(~0.4초).
- 스포티파이 재생목록/앨범(최대 **50,000곡**, `SpotifyCollectionMax`): 웹 플레이어 partner API(익명 토큰은 토크너 `SPOTIFY_TOKENER_URL`)로
첫 100곡만 받아 **곡 정보만** 곧바로 대기열에 넣고 재생을 시작한다. 나머지는 1000곡씩(동시 3페이지) 백그라운드로 이어 붙인다
(`LavalinkManager.appendSpotifyPages`, 1만 곡 기준 ~6초). 정지·모두 지우기·퇴장하면 `queueEpoch`가 바뀌어 이어 받기를 멈춘다.
`-s`면 새 페이지가 올 때마다 그 재생목록 곡끼리 다시 섞는다. partner API가 실패하면 공개 임베드 페이지(100곡), 그것도 실패하면 Lavalink(LavaSrc).
partner API의 persisted query 해시(`Spotify.ts`의 `PARTNER_QUERY`)는 스포티파이가 바꾸면 갱신해야 한다.
유튜브 원곡 매칭은 곡마다 **재생 직전**에 한다(`GuildPlayer.resolvePlayable` → `LavalinkManager.matchSpotifyTrack`).
다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다. 임베드 조회가 실패하면 Lavalink(LavaSrc)로 불러온다.
다음 곡은 현재 곡 재생 중에 미리 변환하고, 원곡을 못 찾은 곡은 건너뛴다.
- 대시보드 `queue_list`는 대기열 앞 200곡과 전체 곡 수(`total`)·총 길이(`totalMs`)만 보낸다. `queue_set`은 그 앞부분(`windowSize`)만 바꾸고
뒤는 그대로 두며, `clearAll`이면 전부 비운다.
스포티파이 음원 자체를 직접 재생하지는 않는다(LavaSrc도 항상 다른 음원으로 미러링하며, 직접 재생은 Premium 계정 + librespot이 필요).
- 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).

View File

@@ -29,6 +29,8 @@ export class GuildPlayer {
private _GDB: GuildType | undefined;
private lastPlayedTrack: QueueTrack | undefined;
public queue: QueueTrack[] = [];
/** 대기열을 통째로 비울 때(정지·끝·모두 지우기)마다 1 증가. 백그라운드로 재생목록을 이어 받는 작업이 중단 여부를 판단한다. */
public queueEpoch = 0;
private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined;
private closedTimer: NodeJS.Timeout | undefined;
@@ -246,9 +248,11 @@ export class GuildPlayer {
}
}
public async addTracks(tracks: Track[], userId: string) {
/** 여러 곡을 대기열에 넣는다. 넣은 대기열 항목을 돌려준다. */
public async addTracks(tracks: Track[], userId: string): Promise<QueueTrack[]> {
const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId });
const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
if (first) {
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
this.prefetch(this.queue[1]);
@@ -258,6 +262,26 @@ export class GuildPlayer {
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
return added;
}
/**
* 재생목록의 나머지 곡을 대기열 뒤에 붙인다(백그라운드로 이어 받는 용도). 디스코드 메시지는 고치지 않고
* 대시보드에만 알린다(페이지마다 메시지를 고치면 디스코드 요청 제한에 걸린다).
* group이 있으면('-s'로 넣은 재생목록) 그 곡들이 있던 자리끼리 다시 섞는다. 지금 곡과 미리 변환 중인 다음 곡은 그대로 둔다.
*/
public appendTracks(tracks: Track[], userId: string, group?: Set<object>) {
const added = tracks.map((track) => ({ ...track, userId }));
for (const item of added) this.queue.push(item);
if (group) {
for (const item of added) group.add(item);
const slots: number[] = [];
for (let i = 2; i < this.queue.length; i++) if (group.has(this.queue[i])) slots.push(i);
const mixed = shuffle(slots.map((i) => this.queue[i]));
slots.forEach((slot, k) => { this.queue[slot] = mixed[k]; });
}
this.prefetch(this.queue[1]);
Redis?.publishState("queue_update", { guildId: this.guild.id });
}
/** 유튜브(및 스포티파이) 트랙을 yt-dlp 리졸버로 직접 오디오 URL로 바꿔 http 소스로 재생.
@@ -349,6 +373,7 @@ export class GuildPlayer {
}
public stop() {
this.queueEpoch++;
this.queue = [];
this.canRecommend = false;
this.player.stopTrack();
@@ -560,6 +585,7 @@ export class GuildPlayer {
this.delete(true);
}, idleEndTime);
this.lastPlayedTrack = undefined;
this.queueEpoch++;
this.queue = [];
this.canRecommend = true;
this.setMsg();

View File

@@ -7,12 +7,19 @@ import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle";
import { Spotify } from "../utils/api/Spotify";
import { Spotify, SpotifyCollectionMax, type SpotifyCollectionPage } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { pickBestMatch, type SongMeta } from "../utils/music/TrackMatch";
/** 스포티파이 재생목록에서 아직 받지 않은 나머지(offset부터 total까지). 대기열 추가 후 백그라운드로 이어 받는다. */
type SpotifyRemaining = { kind: "playlist" | "album"; id: string; offset: number; total: number; cover?: string };
/** 해석 결과. */
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string> };
type Resolved = { tracks: Track[]; playlist: boolean; flags: Set<string>; spotifyRemaining?: SpotifyRemaining };
/** 스포티파이 재생목록 첫 페이지 곡 수(이만큼 받으면 바로 재생 시작) / 이후 백그라운드 페이지 크기·동시 요청 수. */
const SpotifyFirstPage = 100;
const SpotifyPageSize = 1000;
const SpotifyPageConcurrency = 3;
/** 스포티파이 곡 → 유튜브 원곡 매칭 결과 캐시 크기(곡 수). 예열과 실제 재생이 같은 결과를 쓰게 한다. */
const MatchCacheSize = 500;
@@ -122,16 +129,26 @@ export class LavalinkManager {
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const { isUrl, text, flags } = parseLink(query.trim());
// 스포티파이 플레이리스트/앨범: 곡 목록(제목·아티스트·길이)만 받아 곧바로 대기열에 넣는다.
// 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 그래서 100곡짜리도 곡 목록을
// 받는 시간(~0.4초)만 기다리면 된다. 임베드 조회가 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
// 유튜브 변환은 곡마다 재생 직전에 한다(GuildPlayer.resolvePlayable). 첫 100곡(~0.5초)만 받고 바로
// 재생을 시작하며, 나머지(최대 SpotifyCollectionMax곡)는 addResolved 뒤에 백그라운드로 이어 받는다.
// partner API가 실패하면 임베드 페이지(100곡), 그것도 실패하면 아래 일반 경로(Lavalink LavaSrc)로 넘긴다.
const collection = isUrl ? Spotify.parseCollection(text) : null;
if (collection) {
const { tracks: entries, cover } = await Spotify.getCollectionTracks(collection.kind, collection.id);
if (entries.length > 0) {
const tracks = entries.map((e) => Spotify.toLazyTrack(e, cover, collection.kind === "playlist"));
let page: SpotifyCollectionPage | null = await Spotify.getCollectionPage(collection.kind, collection.id, 0, SpotifyFirstPage);
if (!page || page.tracks.length === 0) {
const embed = await Spotify.getCollectionTracks(collection.kind, collection.id);
page = { tracks: embed.tracks, total: 0, cover: embed.cover }; // total 0 → 이어 받지 않음
}
if (page.tracks.length > 0) {
const isPlaylist = collection.kind === "playlist";
const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, page.cover, isPlaylist));
if (flags.has('s')) shuffle(tracks);
this.prewarmTrack(tracks[0]);
return { tracks, playlist: true, flags };
const total = Math.min(page.total, SpotifyCollectionMax);
const spotifyRemaining = total > SpotifyFirstPage
? { kind: collection.kind, id: collection.id, offset: SpotifyFirstPage, total, cover: page.cover }
: undefined;
return { tracks, playlist: true, flags, spotifyRemaining };
}
}
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
@@ -203,7 +220,12 @@ export class LavalinkManager {
) {
if (resolved.playlist) {
// '-s'(섞기)는 resolveQuery에서 이미 적용했다(섞은 뒤 첫 곡을 예열해야 하므로).
await player.addTracks(resolved.tracks, userId);
const added = await player.addTracks(resolved.tracks, userId);
if (resolved.spotifyRemaining) {
const group = resolved.flags.has("s") ? new Set(added) : undefined;
this.appendSpotifyPages(player, resolved.spotifyRemaining, userId, group)
.catch((e) => Logger.warn(`[LavalinkManager] 스포티파이 재생목록 나머지 불러오기 실패: ${String(e)}`));
}
return;
}
await player.addTrack(resolved.tracks[0], userId);
@@ -228,6 +250,41 @@ export class LavalinkManager {
}
}
/**
* 스포티파이 재생목록의 나머지 곡을 페이지 단위로 받아 대기열 뒤에 순서대로 붙인다(곡 정보만, 변환은 재생 직전).
* 정지·모두 지우기·퇴장으로 대기열이 바뀌면(queueEpoch 변경) 중단한다.
* group: '-s'로 넣은 재생목록이면 그 곡들 — 새 페이지가 올 때마다 그 곡들끼리 다시 섞는다.
*/
private async appendSpotifyPages(player: GuildPlayer, rest: SpotifyRemaining, userId: string, group?: Set<object>) {
const epoch = player.queueEpoch;
const stopped = () => this.getPlayer(player.guild.id) !== player || player.queueEpoch !== epoch;
const isPlaylist = rest.kind === "playlist";
let added = 0;
let offset = rest.offset;
while (offset < rest.total) {
const offsets: number[] = [];
for (let k = 0; k < SpotifyPageConcurrency && offset < rest.total; k++, offset += SpotifyPageSize) offsets.push(offset);
const pages = await Promise.all(offsets.map((o) =>
Spotify.getCollectionPage(rest.kind, rest.id, o, Math.min(SpotifyPageSize, rest.total - o))));
if (stopped()) {
Logger.info(`[LavalinkManager] 스포티파이 재생목록 불러오기 중단 (정지/지우기/퇴장), ${added}곡 추가됨`);
return;
}
pages.forEach((page, k) => {
if (!page) {
Logger.warn(`[LavalinkManager] 스포티파이 재생목록 ${offsets[k]}번째부터 한 페이지를 받지 못해 건너뜁니다.`);
return;
}
const tracks = page.tracks.map((e) => Spotify.toLazyTrack(e, rest.cover, isPlaylist));
if (tracks.length === 0) return;
player.appendTracks(tracks, userId, group);
added += tracks.length;
});
}
Logger.info(`[LavalinkManager] 스포티파이 재생목록 나머지 ${added}곡 추가 완료 (전체 ${rest.total}곡 중 ${rest.offset}곡은 먼저 추가)`);
player.setMsg({ player: false, queue: true });
}
/**
* 스포티파이 곡(재생목록 항목·자동재생 추천곡)을 재생할 유튜브 원곡 트랙으로 바꾼다. 못 찾으면 null.
* 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다.

View File

@@ -12,6 +12,12 @@ import { SongItem } from "../types/Track";
import { DB } from "../utils/Database";
import { buttonRecommend } from "../utils/music/Button";
/**
* 대시보드에 보내는 대기열 앞부분 곡 수. 스포티파이 재생목록은 최대 5만 곡이라 전부 보내면 응답이 수십 MB가 되고
* 브라우저가 멈춘다. 곡 수·총 길이는 따로 보내고, 순서 변경(queue_set)은 이 앞부분만 바꾸고 뒤는 그대로 둔다.
*/
const QueueListLimit = 200;
type SubAction =
"search" |
"player_now" |
@@ -159,7 +165,15 @@ class RedisClientClass {
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
const upcoming = player?.queue ?? [];
let totalMs = 0;
for (let i = 1; i < upcoming.length; i++) totalMs += Number(upcoming[i].info?.length) || 0;
await this.reply(resultKey, 60, JSON.stringify({
success: true,
queue: upcoming.slice(1, 1 + QueueListLimit),
total: Math.max(upcoming.length - 1, 0),
totalMs,
}));
}
if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`;
@@ -169,14 +183,20 @@ class RedisClientClass {
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
context.player.queue.length = 0;
if (nowTrack) context.player.queue.push(nowTrack);
// 대시보드는 대기열 앞부분(windowSize곡, 기본 QueueListLimit)만 갖고 있으므로 그 부분만 바꾸고 뒤는 남긴다.
// clearAll(모두 지우기)이면 뒤까지 비우고, 백그라운드로 이어 받던 재생목록도 멈춘다(queueEpoch).
const windowSize = typeof data.windowSize === "number" && data.windowSize >= 0 ? data.windowSize : QueueListLimit;
const tail = data.clearAll === true ? [] : context.player.queue.slice(1 + windowSize);
if (data.clearAll === true) context.player.queueEpoch++;
const head: typeof context.player.queue = [];
if (nowTrack) head.push(nowTrack);
for (const rawTrack of data.newQueue) {
if (!rawTrack?.encoded) continue;
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
const { _resolved, ...track } = rawTrack;
context.player.queue.push(track);
head.push(track);
}
context.player.queue = head.concat(tail);
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg();
}

View File

@@ -5,11 +5,37 @@ import { SongItem } from "../../types/Track";
import type { Track } from "shoukaku";
import type { SongMeta } from "../music/TrackMatch";
/** 스포티파이 재생목록/앨범의 곡 하나(임베드 페이지 기준). */
export type SpotifyEntry = SongMeta & { id: string };
/** 스포티파이 재생목록/앨범의 곡 하나. artworkUrl: 곡의 앨범 커버(임베드 페이지에서 받으면 없음). */
export type SpotifyEntry = SongMeta & { id: string; artworkUrl?: string };
/** 재생목록/앨범 한 페이지. total: 재생목록 전체 항목 수(다음 페이지를 받을지 판단). */
export type SpotifyCollectionPage = { tracks: SpotifyEntry[]; total: number; cover?: string };
/** 재생목록/앨범에서 불러올 최대 곡 수. */
export const SpotifyCollectionMax = 50000;
const LAZY_PREFIX = "spotify-lazy:";
// 스포티파이 웹 플레이어가 쓰는 GraphQL(partner API). 익명 웹 토큰(토크너)으로 재생목록을 한 번에 최대 수천 곡씩
// 받을 수 있다(1000곡 ~1초). persisted query 해시는 Lavalink LavaSrc 4.8.3과 같은 값이며, 스포티파이가 바꾸면
// "PersistedQueryNotFound" 오류가 나므로 그때 갱신해야 한다(실패 시 임베드 페이지 100곡으로 폴백).
const PARTNER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query";
const PARTNER_QUERY = {
playlist: { operationName: "fetchPlaylist", sha256Hash: "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77" },
album: { operationName: "getAlbum", sha256Hash: "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10" },
} as const;
/** 크기별 이미지 목록에서 300px 정도(대기열 썸네일·재생 화면에 충분)를, 없으면 가장 큰 것을 고른다. */
const pickImage = (sources: any): string | undefined => {
if (!Array.isArray(sources) || sources.length === 0) return undefined;
const mid = sources.find((x: any) => x?.width === 300);
const best = mid ?? sources.reduce((a: any, b: any) => ((b?.width ?? 0) > (a?.width ?? 0) ? b : a));
return typeof best?.url === "string" ? best.url : undefined;
};
const artistNames = (artists: any): string =>
(Array.isArray(artists?.items) ? artists.items : []).map((a: any) => a?.profile?.name).filter(Boolean).join(", ");
const trackIdOf = (uri: unknown): string | undefined => String(uri ?? "").match(/^spotify:track:([A-Za-z0-9]+)$/)?.[1];
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
@@ -32,15 +58,20 @@ export const Spotify = {
cached: "",
expiresAt: 0,
},
// 토크너(웹 플레이어) 토큰은 정식 API 토큰(_token)과 쓰임새가 달라 캐시를 따로 둔다.
_webToken: {
cached: "",
expiresAt: 0,
},
async getTokenByTokener(): Promise<string | null> {
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached;
if (this._webToken.cached && Date.now() < this._webToken.expiresAt - 60000) return this._webToken.cached;
try {
const response = await fetch(TOKENR_URL);
const response = await fetch(TOKENR_URL, { signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
const jsonData = await response.json() as TOKENER_RESPONSE;
this._token.cached = jsonData.accessToken;
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs;
this._webToken.cached = jsonData.accessToken;
this._webToken.expiresAt = jsonData.accessTokenExpirationTimestampMs;
return jsonData.accessToken;
} catch (err) {
Logger.error(`토크너 접근 실패: ${err}`);
@@ -148,10 +179,88 @@ export const Spotify = {
},
/**
* 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 가져온다.
* 2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이고,
* Lavalink(LavaSrc) partner API는 되지만 100곡에 7~8초가 걸린다. 공개 임베드 페이지는 한 번(~0.4초)에
* 받을 수 있어 이걸 쓴다. 최대 100곡까지 제공된다. 실패하면 tracks가 빈 배열.
* 플레이리스트/앨범의 곡 목록 한 페이지(offset부터 limit개)를 partner API로 가져온다.
* 곡마다 id·제목·아티스트·길이·앨범 커버가 들어 있다. 로컬 파일·삭제된 곡은 빠진다. 실패하면 null.
*/
async getCollectionPage(kind: "playlist" | "album", id: string, offset: number, limit: number): Promise<SpotifyCollectionPage | null> {
const query = PARTNER_QUERY[kind];
const variables = kind === "playlist"
? { uri: `spotify:playlist:${id}`, offset, limit, enableWatchFeedEntrypoint: false }
: { uri: `spotify:album:${id}`, locale: "", offset, limit };
for (let attempt = 0; attempt < 2; attempt++) {
const token = await this.getTokenByTokener();
if (!token) return null;
try {
const response = await fetch(PARTNER_API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "App-Platform": "WebPlayer" },
body: JSON.stringify({ operationName: query.operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash: query.sha256Hash } } }),
signal: AbortSignal.timeout(30000),
});
if (response.status === 401 && attempt === 0) {
this._webToken.cached = ""; // 토큰 만료 → 새로 받아 한 번 더
continue;
}
if (!response.ok) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 에러 (${response.status}): ${(await response.text()).slice(0, 200)}`);
return null;
}
const json: any = await response.json();
if (kind === "playlist") {
const pl = json?.data?.playlistV2;
if (pl?.__typename !== "Playlist") {
Logger.error(`스포티파이 재생목록을 찾지 못했습니다: ${id} (${pl?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const items: any[] = Array.isArray(pl.content?.items) ? pl.content.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.itemV2?.data;
const trackId = t?.__typename === "Track" ? trackIdOf(t.uri) : undefined;
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.trackDuration?.totalMilliseconds) || 0,
artworkUrl: pickImage(t.albumOfTrack?.coverArt?.sources),
});
}
return { tracks, total: Number(pl.content?.totalCount) || 0, cover: pickImage(pl.images?.items?.[0]?.sources) };
}
const album = json?.data?.albumUnion;
if (album?.__typename !== "Album") {
Logger.error(`스포티파이 앨범을 찾지 못했습니다: ${id} (${album?.__typename ?? JSON.stringify(json?.errors ?? "").slice(0, 200)})`);
return null;
}
const cover = pickImage(album.coverArt?.sources);
const items: any[] = Array.isArray(album.tracksV2?.items) ? album.tracksV2.items : [];
const tracks: SpotifyEntry[] = [];
for (const it of items) {
const t = it?.track;
const trackId = trackIdOf(t?.uri);
if (!trackId || !t.name) continue;
tracks.push({
id: trackId,
title: String(t.name),
artist: artistNames(t.artists),
durationMs: Number(t.duration?.totalMilliseconds) || 0,
artworkUrl: cover,
});
}
return { tracks, total: Number(album.tracksV2?.totalCount) || 0, cover };
} catch (err) {
Logger.error(`스포티파이 ${kind} 곡 목록(partner API) 조회 실패: ${err}`);
return null;
}
}
return null;
},
/**
* 플레이리스트/앨범의 곡 목록(id·제목·아티스트·길이)과 커버 이미지를 공개 임베드 페이지에서 가져온다.
* partner API(getCollectionPage)가 실패할 때 쓰는 폴백이며, 최대 100곡까지만 제공된다. 실패하면 tracks가 빈 배열.
* (2026년 Spotify Web API 정책 변경으로 개발자 앱은 남의 플레이리스트 곡 목록 조회가 403이다)
*/
async getCollectionTracks(kind: "playlist" | "album", id: string): Promise<{ tracks: SpotifyEntry[]; cover?: string }> {
try {
@@ -195,7 +304,8 @@ export const Spotify = {
* 재생목록 곡을 '아직 유튜브로 변환하지 않은' 대기열 항목으로 만든다.
* encoded는 Lavalink 트랙이 아니라 표식(`spotify-lazy:<id>`)이고, 재생 직전에
* GuildPlayer.resolvePlayable 이 유튜브 원곡을 찾아 실제 재생용 트랙으로 바꾼다.
* coverIsPlaylist: 커버가 곡 앨범아트가 아니라 재생목록 커버라서, 변환할 때 곡 이미지로 바꿔도 되는지.
* 곡 자체의 앨범 커버(entry.artworkUrl)가 없으면 cover(재생목록/앨범 커버)를 쓴다.
* coverIsPlaylist: cover가 곡 앨범아트가 아니라 재생목록 커버라서, 변환할 때 곡 이미지로 바꿔도 되는지.
*/
toLazyTrack(entry: SpotifyEntry, cover: string | undefined, coverIsPlaylist: boolean): Track {
return {
@@ -209,10 +319,10 @@ export const Spotify = {
position: 0,
title: entry.title,
uri: `https://open.spotify.com/track/${entry.id}`,
artworkUrl: cover,
artworkUrl: entry.artworkUrl ?? cover,
sourceName: "spotify",
},
pluginInfo: { lazy: true, coverIsPlaylist },
pluginInfo: { lazy: true, coverIsPlaylist: !entry.artworkUrl && coverIsPlaylist },
};
},

View File

@@ -135,6 +135,7 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
| 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) |
| 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) |
| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 |
| 다음 트랙 | (목록 길이) | 앞 200곡만 표시하고 곡 수·총 길이는 전체 기준. 나머지는 "외 N곡"으로 표시(스포티파이 재생목록은 최대 5만 곡) |
| 재생 화면 | ⌄ / Esc | 닫기 |
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 |

View File

@@ -5,6 +5,10 @@ import { botRpc, errorResponse, readJsonBody, requireSession, requireString } fr
interface QueueSetBody {
serverId?: unknown;
newQueue?: unknown;
/** 화면에 있던 대기열 앞부분 곡 수(봇은 이 부분만 newQueue로 바꾸고 뒤는 남긴다) */
windowSize?: unknown;
/** 모두 지우기(뒤에 남은 곡과 불러오는 중인 재생목록까지 비움) */
clearAll?: unknown;
}
export async function POST(request: Request) {
@@ -29,6 +33,8 @@ export async function POST(request: Request) {
serverId: serverIdResult.value,
userId,
newQueue,
windowSize: typeof bodyResult.data.windowSize === "number" ? bodyResult.data.windowSize : undefined,
clearAll: bodyResult.data.clearAll === true,
},
timeoutMs: 5000,
});

View File

@@ -154,7 +154,7 @@ export default function PlayerApp({ serverId, initialQuery }: { serverId: string
<PlayerBar
player={player}
onShuffle={queue.shuffle}
canShuffle={queue.queue.length >= 2}
canShuffle={queue.total >= 2}
isPlayerOpen={isPlayerOpen}
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
/>

View File

@@ -21,7 +21,8 @@ interface QueueListProps {
*/
export default function QueueList({ player, queue, variant }: QueueListProps) {
const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player;
const { queue: items, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue;
// items 는 대기열 앞부분(최대 200곡)만, total·totalMs 는 대기열 전체 기준
const { queue: items, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue;
// "모두 지우기"는 되돌릴 수 없어서 한 번 더 눌러야 실행한다(3초 안에).
const [confirmClear, setConfirmClear] = useState(false);
@@ -31,8 +32,8 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
return () => clearTimeout(t);
}, [confirmClear]);
const totalMs = items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0);
const totalMin = Math.round(totalMs / 60000);
const hiddenCount = Math.max(total - items.length, 0);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@@ -62,8 +63,8 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
<div className="min-w-0">
{isPage && <h2 className="text-2xl font-bold">재생 목록</h2>}
<p className="text-sm text-white/60">
{items.length > 0
? `다음 트랙 ${items.length}곡${totalMin > 0 ? ` · 약 ${totalMin}분` : ""}`
{total > 0
? `다음 트랙 ${total.toLocaleString()}곡${totalMin > 0 ? ` · 약 ${totalMin >= 60 ? `${Math.floor(totalMin / 60).toLocaleString()}시간 ${totalMin % 60}분` : `${totalMin}분`}` : ""}`
: "다음 트랙 없음"}
</p>
</div>
@@ -71,7 +72,7 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
<button
type="button"
onClick={shuffle}
disabled={items.length < 2}
disabled={total < 2}
title="다음 트랙 섞기"
className="flex h-8 items-center gap-1.5 rounded-full bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover disabled:cursor-not-allowed disabled:opacity-40"
>
@@ -86,7 +87,7 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
clear();
} else setConfirmClear(true);
}}
disabled={items.length === 0}
disabled={total === 0}
title="다음 트랙 모두 지우기(지금 곡은 계속 재생)"
className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40
${confirmClear ? "bg-yt-red text-white" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
@@ -157,7 +158,7 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
return (
<li
key={(item.encoded as string) || `${index}-${title}`}
key={`${index}-${(item.encoded as string) || title}`}
draggable
onDragStart={() => {
dragItem.current = index;
@@ -253,6 +254,11 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
</li>
)}
</ul>
{hiddenCount > 0 && (
<p className="px-2 pt-3 text-center text-xs text-white/50">
외 {hiddenCount.toLocaleString()}곡 · 앞의 곡이 재생되면 차례대로 보여요
</p>
)}
</>
)}
</div>

View File

@@ -12,7 +12,16 @@ export function useQueue(selectedServer: DiscordServer | null) {
const { data: session, status } = useSession();
const { showToast } = useToast();
// 봇은 대기열 앞부분(최대 200곡)만 보내고, 전체 곡 수·총 길이는 따로 보낸다(스포티파이 재생목록은 최대 5만 곡).
const [queue, setQueue] = useState<Track[]>([]);
const [total, setTotal] = useState(0);
const [totalMs, setTotalMs] = useState(0);
const applyList = (items: Track[], count?: unknown, ms?: unknown) => {
setQueue(items);
setTotal(typeof count === "number" ? count : items.length);
setTotalMs(typeof ms === "number" ? ms : items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0));
};
const fetchQueue = useCallback(async () => {
if (!selectedServer) return;
@@ -31,13 +40,13 @@ export function useQueue(selectedServer: DiscordServer | null) {
});
const data = await res.json();
if (res.ok && data.success && Array.isArray(data.queue)) {
setQueue(data.queue as Track[]);
applyList(data.queue as Track[], data.total, data.totalMs);
} else {
setQueue([]);
applyList([]);
}
} catch (error) {
console.error("큐 불러오기 실패:", error);
setQueue([]);
applyList([]);
}
}, [selectedServer, session]);
@@ -71,11 +80,16 @@ export function useQueue(selectedServer: DiscordServer | null) {
};
}, [selectedServer, status, fetchQueue]);
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */
const applyReorder = async (newQueue: Track[]) => {
/**
* 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다).
* 봇은 화면에 있던 앞부분(windowSize곡)만 newQueue로 바꾸고 그 뒤 곡은 그대로 둔다. clearAll이면 전부 비운다.
*/
const applyReorder = async (newQueue: Track[], clearAll = false) => {
if (!selectedServer) return;
const previous = queue;
const windowSize = queue.length;
setQueue(newQueue);
if (clearAll) setTotal(0);
try {
const res = await fetch('/api/queue/set', {
@@ -85,6 +99,8 @@ export function useQueue(selectedServer: DiscordServer | null) {
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue,
windowSize,
clearAll,
})
});
const data = await res.json();
@@ -146,6 +162,7 @@ export function useQueue(selectedServer: DiscordServer | null) {
serverId: selectedServer.id,
userId: session?.user?.id,
newQueue: rest,
windowSize: queue.length,
})
});
const setData = await setRes.json();
@@ -201,7 +218,7 @@ export function useQueue(selectedServer: DiscordServer | null) {
/** 다음 트랙 섞기(봇이 섞고 queue_update 로 새 순서를 알려준다) */
const shuffle = async () => {
if (!selectedServer) return;
if (queue.length < 2) {
if (total < 2) {
showToast("섞을 곡이 2곡 이상 있어야 해요.", "error");
return;
}
@@ -225,11 +242,11 @@ export function useQueue(selectedServer: DiscordServer | null) {
/** 다음 트랙 모두 지우기(지금 곡은 계속 재생) */
const clear = async () => {
if (queue.length === 0) return;
await applyReorder([]);
if (total === 0) return;
await applyReorder([], true);
};
return { queue, moveTo, moveBy, playFrom, remove, shuffle, clear };
return { queue, total, totalMs, moveTo, moveBy, playFrom, remove, shuffle, clear };
}
export type QueueState = ReturnType<typeof useQueue>;