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

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