diff --git a/bot/README.md b/bot/README.md index 2eb4fc1..e80f4d8 100644 --- a/bot/README.md +++ b/bot/README.md @@ -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 믹스 폴백). diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index a4fcd36..a7f67c3 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -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 { 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) { + 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(); diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index 55fd149..e5dde5b 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -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 }; +type Resolved = { tracks: Track[]; playlist: boolean; flags: Set; 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) { + 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. * 같은 곡은 결과를 캐시해, 대기열에 넣을 때의 예열과 실제 재생이 검색을 한 번만 하도록 한다. diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index a6c38fe..da5912a 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -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(); } diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index b43627e..84ab8a6 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -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 { - 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 { + 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:`)이고, 재생 직전에 * 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 }, }; }, diff --git a/page/README.md b/page/README.md index bb73b3d..83ac896 100644 --- a/page/README.md +++ b/page/README.md @@ -135,6 +135,7 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브 | 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) | | 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) | | 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 | +| 다음 트랙 | (목록 길이) | 앞 200곡만 표시하고 곡 수·총 길이는 전체 기준. 나머지는 "외 N곡"으로 표시(스포티파이 재생목록은 최대 5만 곡) | | 재생 화면 | ⌄ / Esc | 닫기 | | 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | diff --git a/page/src/app/api/queue/set/route.ts b/page/src/app/api/queue/set/route.ts index 7469957..d4e416e 100644 --- a/page/src/app/api/queue/set/route.ts +++ b/page/src/app/api/queue/set/route.ts @@ -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, }); diff --git a/page/src/components/pages/PlayerApp.tsx b/page/src/components/pages/PlayerApp.tsx index 7e4cf6b..25fd05b 100644 --- a/page/src/components/pages/PlayerApp.tsx +++ b/page/src/components/pages/PlayerApp.tsx @@ -154,7 +154,7 @@ export default function PlayerApp({ serverId, initialQuery }: { serverId: string = 2} + canShuffle={queue.total >= 2} isPlayerOpen={isPlayerOpen} onTogglePlayer={() => setIsPlayerOpen((v) => !v)} /> diff --git a/page/src/components/player/QueueList.tsx b/page/src/components/player/QueueList.tsx index 700eda9..cbee278 100644 --- a/page/src/components/player/QueueList.tsx +++ b/page/src/components/player/QueueList.tsx @@ -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(null); const [dragOverIndex, setDragOverIndex] = useState(null); @@ -62,8 +63,8 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
{isPage &&

재생 목록

}

- {items.length > 0 - ? `다음 트랙 ${items.length}곡${totalMin > 0 ? ` · 약 ${totalMin}분` : ""}` + {total > 0 + ? `다음 트랙 ${total.toLocaleString()}곡${totalMin > 0 ? ` · 약 ${totalMin >= 60 ? `${Math.floor(totalMin / 60).toLocaleString()}시간 ${totalMin % 60}분` : `${totalMin}분`}` : ""}` : "다음 트랙 없음"}

@@ -71,7 +72,7 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {