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