Compare commits

..

5 Commits

Author SHA1 Message Date
f5778a7763 Merge page-improvements: 대시보드 개선 + BRPOP RPC + 로그인 연동 2026-09-13 01:59:48 +09:00
8e3791fb37 fix(page): PlayerBar useRef 초기값의 Date.now() purity lint 에러 해소
react-hooks/purity 가 useRef 초기화의 Date.now() 호출을 에러로 잡는데,
초기값은 최초 렌더 1회만 평가되어 안전하므로 해당 라인만 eslint-disable 처리.
동작 변경 없음. npm run lint 0 errors.
2026-09-13 01:59:25 +09:00
31b64b73c5 refactor(rpc): site↔bot 명령 응답을 SETEX+폴링에서 LPUSH+BRPOP 블로킹으로 전환
- bot: reply() 헬퍼(LPUSH+EXPIRE) 추가, 67개 setex 응답 지점을 일괄 교체.
  결과키를 리스트에 push해 사이트가 BRPOP으로 블로킹 수신하도록 함.
- page(api.ts botRpc): short-polling 제거, 요청마다 전용 커넥션(duplicate)으로
  BRPOP 대기 → 응답 즉시 수신(폴링 지연/헛 GET 제거), 전용 커넥션은 finally에서 정리.
- search route: 사라진 pollIntervalMs 옵션 제거.

검증: 테스트봇(.9)+.7 Redis 실측 BRPOP 왕복 10ms, 고아 키 없음. bot tsc/page next build 통과.
2026-09-12 23:58:52 +09:00
tkrmagid
221c31bfa1 chore(page): 대시보드 배포 스캐폴딩 추가(Dockerfile + compose 예시, OAuth redirect 안내) 2026-09-12 22:56:58 +09:00
tkrmagid
1efa55c3ec fix(page+bot): 리뷰 지적사항 반영 - 서버 멤버십·같은음성채널 인가(1,2), SSE 공유구독 fan-out(3), botRpc 즉시폴링(4), 큐삭제 encoded 검증(6), 진행바 anchor 타이머(7), 캐시 파싱 가드(8), key 안정화(9), 볼륨 롤백(10), SSE 자동재연결(11), body userId 정리(12) 2026-09-12 22:49:56 +09:00
10 changed files with 291 additions and 212 deletions

View File

@@ -63,48 +63,56 @@ class RedisClientClass {
thumbnail: video.info.artworkUrl ?? "",
duration: video.info.length,
}));
await this.pub.setex(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`);
}
if (data.action === "player_play") {
const resultKey = `player:play:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
}
if (data.action === "player_playlist") {
const resultKey = `player:playlist:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
}
if (data.action === "player_now") {
const resultKey = `player:now:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const nowGuild = await getGuildById(data.serverId);
if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({
await this.reply(resultKey, 60, JSON.stringify({
success: true,
botPlayer: !!player,
isPlaying: player?.isPlaying,
@@ -116,17 +124,19 @@ class RedisClientClass {
}
if (data.action === "queue_list") {
const resultKey = `queue:list:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const qlGuild = await getGuildById(data.serverId);
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);
// if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
}
if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
@@ -135,79 +145,85 @@ class RedisClientClass {
for (const rawTrack of data.newQueue) {
if (rawTrack.encoded) context.player.queue.push(rawTrack);
}
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg();
}
if (data.action === "queue_remove") {
const resultKey = `queue:remove:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// index 는 number(0 도 유효) — typeof 검증으로 변경.
if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const numIndex = Number(data.index);
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
const removeTarget = context.player.queue[numIndex + 1];
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
}
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
}
if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
await context.player.setPause();
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
}
if (data.action === "player_skip") {
const resultKey = `player:skip:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.isPlaying) context.player.skip();
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_seek") {
const resultKey = `player:seek:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const duration = context.player.nowTrack.info.length || 0;
const numSeek = Number(data.seek);
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
context.player.seek(numSeek);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_volume") {
const resultKey = `player:volume:${data.requestId}`;
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const numVolume = Number(data.volume);
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
context.player.setVolume(numVolume);
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true }));
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
} catch (err) {
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
@@ -222,6 +238,17 @@ class RedisClientClass {
});
}
/**
* 사이트로 보내는 RPC 응답 쓰기.
* 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다.
* ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험.
* (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체)
*/
private async reply(key: string, ttlSec: number, value: string): Promise<void> {
await this.pub.lpush(key, value);
await this.pub.expire(key, ttlSec);
}
public publishState(event: string, data: any) {
const payload = JSON.stringify({
event,
@@ -232,6 +259,16 @@ class RedisClientClass {
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
}
/**
* 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch).
* 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증.
*/
private async isMember(guild: Guild, userId: string): Promise<boolean> {
if (guild.members.cache.has(userId)) return true;
const fetched = await guild.members.fetch(userId).catch(() => null);
return !!fetched;
}
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
ok: true;
guild: Guild;
@@ -240,20 +277,29 @@ class RedisClientClass {
} | { ok: false; }> {
const guild = await getGuildById(guildId);
if (!guild) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
return { ok: false };
}
// 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단)
if (!(await this.isMember(guild, userId))) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
return { ok: false };
}
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, userId);
if (!player) {
if (!voiceChannel) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
return { ok: false };
}
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
// 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
return { ok: false };
}
if (!player) {
await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
return { ok: false };
}
return {

21
page/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# music_bot_v2 대시보드(page/) — Next.js 16
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# next build 시 라우트 모듈 평가 단계에서 env 미설정이면 throw 되므로, 빌드용 더미 env를 넣는다.
# (실제 값은 런타임에 compose의 environment로 주입 — 아래 값은 빌드 통과용 placeholder)
ENV DISCORD_CLIENT_ID=build \
DISCORD_CLIENT_SECRET=build \
NEXTAUTH_SECRET=build \
NEXTAUTH_URL=http://localhost:3000 \
REDIS_HOST=127.0.0.1 \
REDIS_PORT=6379
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -0,0 +1,21 @@
# music_bot_v2 대시보드(page/) 배포 예시.
# 운영 .5 호스트에서는 /root/bot/ 아래에 이 내용을 두고 `docker compose -f ... up -d --build` 로 띄운다.
#
# ⚠️ 로그인(Discord OAuth) 전제조건:
# Discord 개발자 포털 → music_bot 앱(APPID 1412338882934079498) → OAuth2 → Redirects 에
# "<NEXTAUTH_URL>/api/auth/callback/discord" 를 반드시 등록해야 로그인이 된다.
# (테스트 포트로 띄우면 예: http://192.168.10.5:2960/api/auth/callback/discord)
services:
music_site_test:
build: ./music_bot_v2/page # (테스트 시엔 page-improvements 브랜치 체크아웃 상태)
container_name: music_site_test
restart: always
ports:
- "2960:3000" # 테스트용 포트(다른 사이트와 겹치지 않게)
environment:
- DISCORD_CLIENT_ID=1412338882934079498
- DISCORD_CLIENT_SECRET=<music_bot 앱 client_secret>
- NEXTAUTH_SECRET=<openssl rand -base64 32 로 생성>
- NEXTAUTH_URL=http://192.168.10.5:2960
- REDIS_HOST=192.168.10.7 # 운영 봇과 같은 Redis(실봇 제어·상태 테스트). 격리 원하면 별도 Redis.
- REDIS_PORT=6379

View File

@@ -12,6 +12,7 @@ import {
interface QueueRemoveBody {
serverId?: unknown;
index?: unknown;
encoded?: unknown;
}
export async function POST(request: Request) {
@@ -37,6 +38,8 @@ export async function POST(request: Request) {
serverId: serverIdResult.value,
userId,
index: indexResult.value,
// 인덱스-트랙 일치 검증용(봇이 대상 encoded 불일치 시 거절). 문자열일 때만 전달.
encoded: typeof bodyResult.data.encoded === "string" ? bodyResult.data.encoded : undefined,
},
timeoutMs: 5000,
});

View File

@@ -20,7 +20,6 @@ export async function GET(request: Request) {
query,
},
timeoutMs: 10000,
pollIntervalMs: 250,
});
return NextResponse.json(body, { status });
} catch (error) {

View File

@@ -55,7 +55,7 @@ export default function MainContent({
}
let endpoint = "";
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
@@ -108,8 +108,16 @@ export default function MainContent({
setIsFetching(true);
const cached = sessionStorage.getItem("filtered_servers");
if (cached) {
setServers(JSON.parse(cached));
setIsFetching(false);
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setIsFetching(false);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem("filtered_servers");
}
}
fetch("/api/servers")
@@ -162,7 +170,7 @@ export default function MainContent({
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
const renderTrackCard = (track: SearchTrack) => (
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
<button

View File

@@ -22,6 +22,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const [position, setPosition] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
// 진행바 계산 기준점: 이 시점(at)에 위치가 pos였다 → 현재위치 = pos + (now - at)
// useRef 초기값은 최초 렌더 1회만 평가되므로 Date.now() 호출이 안전(리렌더마다 재실행 안 됨).
// eslint-disable-next-line react-hooks/purity
const anchorRef = useRef<{ pos: number; at: number }>({ pos: 0, at: Date.now() });
const prevVolumeRef = useRef<number>(50); // 드래그 시작 시점 볼륨(실패 시 롤백용)
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
@@ -38,7 +43,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
const data = await res.json();
@@ -52,7 +56,9 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
setVolume(typeof data.volume === "number" ? data.volume : 50);
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
if (!isDragging.current) {
setPosition(data.position || 0);
const p = data.position || 0;
setPosition(p);
anchorRef.current = { pos: p, at: Date.now() }; // 기준점 재설정
}
} else {
setTrack(null);
@@ -84,15 +90,16 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
console.warn("SSE JSON 파싱 실패:", err);
}
};
eventSource.onerror = (error) => {
console.error("Player SSE 연결 오류:", error);
eventSource.close();
// 에러 시 close 하지 않는다 — EventSource가 자동 재연결하도록 둔다(일시적 네트워크 끊김 복구).
eventSource.onerror = () => {
console.warn("Player SSE 일시 오류 — 자동 재연결 대기");
};
return () => eventSource.close();
}, [selectedServer, fetchNowPlaying]);
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
// 3. 진행바 시계: setInterval 누적(+1000ms) 방식은 탭 비활성/지터로 실제와 어긋나므로,
// "기준점(anchor) + 실제 경과시간(Date.now())" 으로 계산해 드리프트를 없앤다.
// anchor 는 서버 동기화/탐색/일시정지 전환 시점마다 갱신된다(아래 setAnchor).
const isPausedRef = useRef(isPaused);
useEffect(() => {
isPausedRef.current = isPaused;
@@ -100,25 +107,20 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
useEffect(() => {
if (!isPlaying) return;
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
const localInterval = setInterval(() => {
// 250ms마다 anchor 기준으로 재계산(부드러운 진행)
const tick = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
setPosition((prev) => {
if (prev >= duration) return duration;
return prev + 1000;
});
}, 1000);
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
const a = anchorRef.current;
const next = a.pos + (Date.now() - a.at);
setPosition(duration > 0 ? Math.min(duration, next) : next);
}, 250);
// 15초마다 서버 시간으로 오차 교정(anchor 재설정은 fetchNowPlaying 내부에서)
const syncInterval = setInterval(() => {
if (isPausedRef.current || isDragging.current) return;
fetchNowPlaying();
}, 10000);
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
}, 15000);
return () => {
clearInterval(localInterval);
clearInterval(tick);
clearInterval(syncInterval);
};
}, [isPlaying, duration, fetchNowPlaying]);
@@ -135,13 +137,13 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
const nextPaused = !isPaused;
// UI 즉각 반영 (Optimistic UI)
setIsPaused(nextPaused);
anchorRef.current = { pos: position, at: Date.now() }; // 일시정지/재개 시점 기준 갱신
try {
const res = await fetch('/api/player/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
isPaused: nextPaused, // boolean 그대로 전송
})
});
@@ -170,7 +172,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
})
});
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
@@ -189,6 +190,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
const newPosition = Number(e.currentTarget.value);
setPosition(newPosition);
anchorRef.current = { pos: newPosition, at: Date.now() }; // 탐색 후 기준 갱신
try {
await fetch('/api/player/seek', {
@@ -197,7 +200,6 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
body: JSON.stringify({
serverId: selectedServer.id,
seek: newPosition,
userId: userId,
})
});
} catch (error) {
@@ -221,17 +223,19 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
setVolume(finalVolume); // UI 즉시 반영
try {
await fetch('/api/player/volume', {
const res = await fetch('/api/player/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
volume: finalVolume,
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.success) setVolume(prevVolumeRef.current); // 실패 시 롤백
} catch (error) {
console.error("볼륨 조절 에러:", error);
setVolume(prevVolumeRef.current); // 실패 시 롤백
}
};
@@ -349,8 +353,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
value={volume}
disabled={!botPlayer || !isPlaying || !track}
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
onMouseDown={() => setIsVolumeDragging(true)}
onTouchStart={() => setIsVolumeDragging(true)}
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"

View File

@@ -70,9 +70,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
}
};
eventSource.onerror = (error) => {
console.error("SSE 연결 오류:", error);
eventSource.close();
// 에러 시 close 하지 않는다 — EventSource 자동 재연결에 맡긴다(일시 끊김 복구).
eventSource.onerror = () => {
console.warn("Queue SSE 일시 오류 — 자동 재연결 대기");
};
return () => {
@@ -142,8 +142,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const handleDelete = async (indexToRemove: number) => {
if (!selectedServer) return;
const userId = session?.user?.id;
// 지우려는 곡의 encoded 를 함께 보내 서버가 인덱스-트랙 일치를 검증(엉뚱한 곡 삭제 방지).
const targetEncoded = queue[indexToRemove]?.encoded;
const newQueue = queue.filter((_, index) => index !== indexToRemove);
setQueue(newQueue);
@@ -153,8 +154,8 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: selectedServer.id,
userId: userId,
index: indexToRemove,
encoded: targetEncoded,
})
});
const data = await res.json();
@@ -185,7 +186,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
return (
<div
key={`${item.id || index}-${item.info?.title || ''}`}
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
draggable
onDragStart={() => {
dragItem.current = index;

View File

@@ -25,35 +25,23 @@ export async function requireSession(): Promise<
}
// ========== 봇 RPC 헬퍼 ==========
// site → bot: Redis Pub/Sub 으로 명령 전송
// bot → site: Redis SET 으로 결과 저장 (resultKey)
// 사이트는 resultKey 를 short polling 으로 확인.
// site → bot: Redis Pub/Sub 으로 명령 전송 (채널 "site-bot")
// bot → site: 봇이 결과를 resultKey 리스트에 LPUSH
// 사이트는 resultKey 를 BRPOP 으로 블로킹 대기한다(폴링 없음 → 응답 즉시, 헛 GET 제거).
export interface BotRpcOptions {
/** Redis 결과 키 prefix (e.g. "player:now") */
channel: string;
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
payload: Record<string, unknown>;
/** 폴링 총 타임아웃 (ms). 기본 3000. */
/** 응답 대기 타임아웃 (ms). 기본 3000. */
timeoutMs?: number;
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
pollIntervalMs?: number;
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
resultTtlSec?: number;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function botRpc(
opts: BotRpcOptions,
): Promise<{ status: number; body: Record<string, unknown> }> {
const {
channel,
payload,
timeoutMs = 3000,
pollIntervalMs = 100,
resultTtlSec = 5,
} = opts;
const { channel, payload, timeoutMs = 3000 } = opts;
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
const requestId = `req:${randomUUID()}`;
@@ -65,19 +53,24 @@ export async function botRpc(
JSON.stringify({ ...payload, requestId }),
);
const deadline = Date.now() + timeoutMs;
let interval = pollIntervalMs;
// BRPOP 은 응답이 올 때까지 커넥션을 블로킹하므로, 공유 커넥션을 막지 않도록
// 요청마다 전용 커넥션을 복제해서 쓰고 끝나면 닫는다.
// (봇이 LPUSH 를 이미 끝냈다면 BRPOP 은 즉시 반환하므로 publish→BRPOP 순서 경쟁 문제 없음)
const waiter = Redis.duplicate();
// BRPOP 타임아웃은 초 단위. ms 를 올림(최소 1초)해서 전달.
const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
while (Date.now() < deadline) {
await sleep(interval);
interval = Math.min(interval * 2, 400);
const reply = await Redis.get(resultKey);
if (!reply) continue;
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
await Redis.del(resultKey);
try {
// brpop → [key, value] | null(타임아웃)
const popped = await waiter.brpop(resultKey, timeoutSec);
if (!popped) {
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
const reply = popped[1];
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(reply);
@@ -92,20 +85,10 @@ export async function botRpc(
const hasSuccessField = "success" in parsed;
const ok = !hasSuccessField || parsed.success === true;
return { status: ok ? 200 : 400, body: parsed };
} finally {
// 전용 커넥션 정리 (블로킹 중이면 즉시 끊기)
waiter.disconnect();
}
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
try {
await Redis.expire(resultKey, resultTtlSec);
} catch {
// 무시: 정리 실패는 치명적 아님
}
return {
status: 504,
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
};
}
// ========== POST 본문 파싱 ==========

View File

@@ -17,12 +17,47 @@ interface BotEventStreamOptions {
clientEventType?: string;
}
// ─────────────────────────────────────────────────────────────
// 공유 구독자(fan-out): "bot-site" 채널은 프로세스당 구독자 1개만 두고,
// 메모리에서 리스너들에게 분배한다. (SSE 연결마다 Redis 커넥션을 복제하지 않음)
// ─────────────────────────────────────────────────────────────
interface Listener {
guildId: string;
botEventName: string;
send: () => void;
}
const listeners = new Set<Listener>();
const globalForSse = global as unknown as { botSiteSubscriber?: ReturnType<typeof Redis.duplicate> };
function ensureSubscriber() {
if (globalForSse.botSiteSubscriber) return;
const sub = Redis.duplicate();
globalForSse.botSiteSubscriber = sub;
sub.on("error", (err) => Logger.error(`[SSE] shared subscriber error: ${err.message}`));
sub.subscribe("bot-site").catch((err) => Logger.error(`[SSE] subscribe 실패: ${String(err)}`));
sub.on("message", (channel, message) => {
if (channel !== "bot-site") return;
let data: BotEvent;
try {
data = JSON.parse(message) as BotEvent;
} catch (err) {
Logger.warn(`[SSE] 잘못된 JSON: ${String(err)}`);
return;
}
for (const l of listeners) {
if (data.guildId === l.guildId && data.event === l.botEventName) {
try { l.send(); } catch { /* 개별 클라이언트 전송 실패는 무시 */ }
}
}
});
}
/**
* 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러.
* 봇이 publish 하는 "bot-site" 채널을 공유 구독자로 받아 SSE 로 흘려보내는 공용 핸들러.
* - 인증 가드: 세션 없으면 401
* - serverId 검증
* - JSON.parse 안전 처리
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
* - 공유 구독자 + 메모리 fan-out (연결마다 Redis 커넥션 복제 안 함)
* - keepalive ping (30초)
*/
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
@@ -38,83 +73,41 @@ export async function botEventStream(req: NextRequest, opts: BotEventStreamOptio
return new Response("Missing serverId", { status: 400 });
}
const stream = new ReadableStream({
async start(controller) {
const subscriber = Redis.duplicate();
let closed = false;
const timers: NodeJS.Timeout[] = [];
ensureSubscriber();
const cleanup = async () => {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
for (const t of timers) clearInterval(t);
try {
await subscriber.unsubscribe("bot-site");
} catch {
/* noop */
}
try {
await subscriber.quit();
} catch {
/* noop */
}
try {
controller.close();
} catch {
/* 이미 닫혔을 수 있음 */
}
clearInterval(ping);
listeners.delete(listener);
try { controller.close(); } catch { /* 이미 닫힘 */ }
};
subscriber.on("error", async (err) => {
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
await cleanup();
});
try {
await subscriber.subscribe("bot-site");
} catch (err) {
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
await cleanup();
return;
}
subscriber.on("message", (channel, message) => {
if (channel !== "bot-site" || closed) return;
let data: BotEvent;
try {
data = JSON.parse(message) as BotEvent;
} catch (err) {
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
return;
}
if (data.guildId !== serverId) return;
if (data.event !== opts.botEventName) return;
try {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
);
} catch (err) {
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
void cleanup();
}
});
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
timers.push(
setInterval(() => {
const listener: Listener = {
guildId: serverId,
botEventName: opts.botEventName,
send: () => {
if (closed) return;
try {
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
} catch {
void cleanup();
}
}, 30000),
);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`));
},
};
listeners.add(listener);
// 클라이언트가 연결을 끊으면 정리
req.signal.addEventListener("abort", () => {
void cleanup();
});
const ping = setInterval(() => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`: keep-alive\n\n`));
} catch {
cleanup();
}
}, 30000);
req.signal.addEventListener("abort", cleanup);
},
});