feat: 로그인·서버 선택 페이지 분리 + 플레이어 기능 추가(섞기·자동재생·주소 불러오기 등)

page
- /login(디스코드 로그인 전용), /servers(서버 선택 전용), /servers/[id](유튜브 뮤직 플레이어)로 분리
  · 서버 컴포넌트에서 로그인 검사, callbackUrl 은 같은 사이트 경로만 허용
  · NextAuth 로그인/오류 화면도 /login 으로
- 서버 선택: 최근 사용 서버 맨 앞 "최근" 표시, 새로고침, 8개 초과 시 이름 검색, 오류 표시
- 플레이어: 검색어를 ?q= 로 남겨 뒤로가기 지원, 탭 제목에 지금 곡, 내 서버가 아니면 안내
- 검색창: 최근 검색어 목록, 유튜브·스포티파이 주소를 붙여넣으면 곡/재생목록 바로 불러오기
- 다음 트랙: 섞기, 모두 지우기(두 번 눌러 확인), 자동재생 스위치, 곡 수·총 길이
- 재생바: 처음부터 다시(⏮), 섞기 버튼 / 서버 홈: 음성채널 표시, 사용 안내, 최근 검색 칩
- 곡 추가 중 스피너 + 중복 추가 방지, 검색 실패 시 다시 시도, play/playlist 응답 대기 15초
- 계정 전환형 프로필 메뉴와 사이드바 서버 목록 제거(프로필은 서버 변경·로그아웃만)

bot
- Redis 액션 queue_shuffle, player_recommend 추가
- player_now 응답에 recommend(자동재생), voiceChannelName 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:08:29 +09:00
parent a491a1a98e
commit 7021f2b776
30 changed files with 1384 additions and 625 deletions

View File

@@ -159,7 +159,9 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
사이트는 그 키를 짧게 폴링해 결과를 받는다.
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`,
`queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음).
- `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다.
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.

View File

@@ -9,6 +9,8 @@ import { channelJoin } from "../commands/join";
import { GuildPlayer } from "./GuildPlayer";
import { Guild, VoiceChannel } from "discord.js";
import { SongItem } from "../types/Track";
import { DB } from "../utils/Database";
import { buttonRecommend } from "../utils/music/Button";
type SubAction =
"search" |
@@ -21,7 +23,9 @@ type SubAction =
"player_volume" |
"queue_list" |
"queue_set" |
"queue_remove";
"queue_remove" |
"queue_shuffle" |
"player_recommend";
export const RedisClient = () => {
if (Config.redis.state) return new RedisClientClass();
@@ -141,7 +145,10 @@ class RedisClientClass {
isPaused: player?.isPaused,
position: player?.position,
volume: player?.volume,
track: player?.nowTrack ?? null
track: player?.nowTrack ?? null,
// 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름
recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false),
voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null,
}));
}
if (data.action === "queue_list") {
@@ -194,6 +201,39 @@ class RedisClientClass {
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
}
if (data.action === "queue_shuffle") {
const resultKey = `queue:shuffle:${data.requestId}`;
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.queue.length < 3) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "섞을 곡이 2곡 이상 있어야 합니다." }));
context.player.setShuffle();
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_recommend") {
// 자동재생 토글. 재생 중이 아니어도 바꿀 수 있어야 하므로 음성채널 접속(getContext)을 거치지 않는다.
const resultKey = `player:recommend:${data.requestId}`;
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 recGuild = await getGuildById(data.serverId);
if (!recGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(recGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(recGuild.id);
if (player) {
// 재생 중이면 봇과 같은 음성채널에 있는 사람만 바꿀 수 있다(다른 조작과 같은 규칙).
const voiceChannel = await getVoiceChannelById(recGuild, data.userId);
if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
}
player.setRecommend();
} else {
await buttonRecommend(recGuild);
}
const recommend = DB.guild.get(recGuild.id)?.options.recommend ?? false;
await this.reply(resultKey, 60, JSON.stringify({ success: true, recommend }));
this.publishState("player_update", { guildId: recGuild.id });
}
if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));

View File

@@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
});
}
const buttonRecommend = async (guild: Guild) => {
export const buttonRecommend = async (guild: Guild) => {
const gdb = DB.guild.get(guild.id);
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
if (!change) return;