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

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