diff --git a/README.md b/README.md index 9f523db..f2a4ada 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,12 @@ ### 대충 내용 아마 따로 작성하지 않을까...? + +#### 마지막으로 만들었던 내용 +검색하고 재생 누르면 재생됨 +플레이리스트 재생 기능(주소 전달해서 재생하는 방식) +#### 이제 만들어야되는 내용 +아래 재생바 연동 +오른쪽 재생목록 연동 +왼쪽 플레이리스트 가져오기 제작 +플레이리스트 재생 추가 diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 5ced478..1201621 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -3,6 +3,11 @@ import { Config } from "../utils/Config"; import { Logger } from "../utils/Logger"; import { YoutubeMusic } from "../utils/api/YoutubeMusic"; import { Spotify } from "../utils/api/Spotify"; +import { lavalinkManager } from "../index"; +import { getGuildById, getVoiceChannelById } from "../utils/music/Channel"; +import { channelJoin } from "../commands/join"; + +type SubAction = "search" | "player_play" | "player_playlist"; export class RedisClient { public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port }); @@ -21,18 +26,50 @@ export class RedisClient { Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`); }); - this.sub.on("message", async (ch, msg) => { + this.sub.on("message", async (ch, msg): Promise => { if (ch !== "site-bot") return; Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`); try { - const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; }; + const data = JSON.parse(msg) as { action: SubAction; requestId: string; userId?: string; [key: string]: any; }; if (data.action === "search") { - const resultKey = `search:result:${data.requestId}`; + const resultKey = `search:${data.requestId}`; const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? []; await this.pub.setex(resultKey, 60, JSON.stringify(results)); Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`); } + else 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를 찾을수 없습니다." })); + const guild = await getGuildById(data.serverId); + if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + 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: "음성채널에 들어가서 이용해주세요." })); + player = (await channelJoin(guild, voiceChannel.id)).player; + } + if (!player) return await this.pub.setex(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: "노래 추가 완료" })); + } + else if (data.action === "player_playlist") { + 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를 찾을수 없습니다." })); + const guild = await getGuildById(data.serverId); + if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + 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: "음성채널에 들어가서 이용해주세요." })); + player = (await channelJoin(guild, voiceChannel.id)).player; + } + if (!player) return await this.pub.setex(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: "플레이리스트 추가 완료" })); + } } catch (err) { Logger.error(`명령어 처리 중 에러: ${String(err)}`); } diff --git a/bot/src/types/Track.d.ts b/bot/src/types/Track.d.ts index f3f5f85..68e453c 100644 --- a/bot/src/types/Track.d.ts +++ b/bot/src/types/Track.d.ts @@ -1,7 +1,8 @@ export interface SongItem { + videoId: string; + url: string; title: string; artist: string; - videoId: string; thumbnail: string; // 썸네일 URL duration: number; // 재생시간 (ms 단위) } \ No newline at end of file diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index 48a5f09..dac3332 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -109,10 +109,11 @@ export const Spotify = { return data.tracks.items.map((track) => ({ videoId: track.id, + url: `https://open.spotify.com/track/${track.id}`, title: track.name, artist: track.artists.map(artist => artist.name).join(", "), duration: track.duration_ms, - thumbnail: track.album.images[2]?.url, + thumbnail: track.album.images[0]?.url, })); } catch (err) { Logger.error(`스포티파이 검색 실패: ${err}`); @@ -123,8 +124,7 @@ export const Spotify = { const lowerQuery = query.toLocaleLowerCase().trim(); if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null; const track = (await this.getSearchFull(query) ?? [])?.[0]; - const url = track?.videoId ? `https://open.spotify.com/track/${track.videoId}` : null; - if (url) searchCache.set(lowerQuery, url); - return url; + if (track.url) searchCache.set(lowerQuery, track.url); + return track.url; } } \ No newline at end of file diff --git a/bot/src/utils/api/YoutubeMusic.ts b/bot/src/utils/api/YoutubeMusic.ts index af8af50..81ff52e 100644 --- a/bot/src/utils/api/YoutubeMusic.ts +++ b/bot/src/utils/api/YoutubeMusic.ts @@ -146,9 +146,10 @@ export const YoutubeMusic = { if (videoId && title) { results.push({ + videoId, + url: `https://music.youtube.com/watch?v=${videoId}`, title, artist, - videoId, thumbnail, duration: parseDurationToMs(durationStr) }); @@ -187,9 +188,10 @@ export const YoutubeMusic = { if (videoId && title) { results.push({ + videoId, + url: `https://music.youtube.com/watch?v=${videoId}`, title, artist, - videoId, thumbnail, duration: parseDurationToMs(durationStr || "") }); @@ -209,8 +211,7 @@ export const YoutubeMusic = { const lowerQuery = query.toLocaleLowerCase().trim(); if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null; const video = (await this.getSearchFull(query) ?? [])?.[0]; - const url = video?.videoId ? `https://music.youtube.com/watch?v=${video.videoId}` : null; - if (url) searchCache.set(lowerQuery, url); - return url; + if (video.url) searchCache.set(lowerQuery, video.url); + return video.url; } }; \ No newline at end of file diff --git a/bot/src/utils/music/Channel.ts b/bot/src/utils/music/Channel.ts index 0ea7594..abbe6de 100644 --- a/bot/src/utils/music/Channel.ts +++ b/bot/src/utils/music/Channel.ts @@ -3,6 +3,19 @@ import { DB } from "../Database"; import { Config } from "../Config"; import { Logger } from "../Logger"; import { clearAllMsg } from "./Utils"; +import { client } from "../../index"; + +export const getGuildById = async (guildId: string): Promise => { + const guild = client.guilds.cache.get(guildId)?.fetch(); + if (!guild) return null; + return guild; +} + +export const getMemberById = async (guild: Guild, userId: string): Promise => { + const member = await guild.members.cache.get(userId)?.fetch(true); + if (!member) return null; + return member; +} export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => { if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel; @@ -10,7 +23,7 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => { return null; } -export const getVoiceChannelById = async(guild: Guild, userId: string): Promise => { +export const getVoiceChannelById = async (guild: Guild, userId: string): Promise => { if (!guild) return null; const member = await guild.members.cache.get(userId)?.fetch(true); if (!member) return null; diff --git a/page/src/app/api/auth/[...nextauth]/route.ts b/page/src/app/api/auth/[...nextauth]/route.ts index 8dcb3fd..d17448a 100644 --- a/page/src/app/api/auth/[...nextauth]/route.ts +++ b/page/src/app/api/auth/[...nextauth]/route.ts @@ -15,13 +15,15 @@ export const authOptions: NextAuthOptions = { }, callbacks: { // 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직 - async jwt({ token, account }) { - if (account) { + async jwt({ token, account, profile }) { + if (account && (profile as any)?.id) { + token.id = (profile as any).id; token.accessToken = account.access_token; } return token; }, async session({ session, token }: any) { + session.user.id = token.id; session.accessToken = token.accessToken; return session; }, diff --git a/page/src/app/api/player/play/route.ts b/page/src/app/api/player/play/route.ts new file mode 100644 index 0000000..d64eda5 --- /dev/null +++ b/page/src/app/api/player/play/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { Redis } from "@/lib/Redis"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { serverId, userId, track } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_play' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_play", + requestId: requestId, + serverId: serverId, + userId: userId, + track: track, + })); + + // 3. 봇의 대답 기다리기 (최대 약 3초 대기) + for (let i = 0; i < 15; i++) { + await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 + const botReply = await Redis.get(resultKey); + if (botReply) { + // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 + await Redis.del(resultKey); + const replyData = JSON.parse(botReply); + // replyData.success 가 false면 에러 상태코드(400)로 보냄 + return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); + } + } + + // 3초가 지나도 봇이 묵묵부답일 때 + return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + } catch (error) { + console.error("Play API Error:", error); + return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/player/playlist/route.ts b/page/src/app/api/player/playlist/route.ts new file mode 100644 index 0000000..5acbe1f --- /dev/null +++ b/page/src/app/api/player/playlist/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { Redis } from "@/lib/Redis"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { serverId, userId, playlistUrl } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_playlist' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_playlist", + requestId: requestId, + serverId: serverId, + userId: userId, + playlistUrl: playlistUrl, + })); + + // 3. 봇의 대답 기다리기 (최대 약 3초 대기) + for (let i = 0; i < 15; i++) { + await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기 + const botReply = await Redis.get(resultKey); + if (botReply) { + // 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달 + await Redis.del(resultKey); + const replyData = JSON.parse(botReply); + // replyData.success 가 false면 에러 상태코드(400)로 보냄 + return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 }); + } + } + + // 3초가 지나도 봇이 묵묵부답일 때 + return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 }); + } catch (error) { + console.error("Queue Adds API Error:", error); + return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/search/route.ts b/page/src/app/api/search/route.ts index 107d372..77d4de4 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -13,7 +13,7 @@ export async function GET(request: Request) { // 2. 고유한 요청 ID 생성 (예: 1690001234567-abc) const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - const resultKey = `search:result:${requestId}`; + const resultKey = `search:${requestId}`; // 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish) await Redis.publish("site-bot", JSON.stringify({ diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index b4da878..f641a90 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; -import { Play, ChevronLeft, Server, Music, Loader2, SearchX } from "lucide-react"; +// 🌟 [수정됨] 상단 아이콘에 쓸 ListPlus 추가 +import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react"; import { ViewMode } from "@/app/page"; interface MainContentProps { @@ -29,6 +30,47 @@ export default function MainContent({ const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + // 🌟 [추가됨] 봇에게 재생 및 대기열 추가 명령을 내리는 함수 + const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => { + if (!selectedServer) { + alert("명령을 내릴 디스코드 서버가 선택되지 않았습니다."); + return; + } + + // 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기 + // (NextAuth 설정에 따라 id가 없을 수도 있으므로 없을 경우 name을 대체로 사용합니다) + const userId = (session?.user as any)?.id; + + let endpoint = ""; + let bodyData: any = { serverId: selectedServer.id, userId: userId }; + if (actionType === 'player_play') { + endpoint = "/api/player/play"; + bodyData.track = track; + } else if (actionType === 'player_playlist') { + endpoint = "/api/player/playlist"; + bodyData.playlistUrl = playlistUrl; + } + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(bodyData), + }); + + const data = await res.json(); + + if (res.ok && data.success) { + alert(data.message || "요청이 성공했습니다."); + } else { + alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + } + } catch (error) { + console.error("Music Action Error:", error); + alert('서버와 통신할 수 없습니다.'); + } + }; + // 권한 계산 함수 const getPermissionLabel = (server: any) => { if (!server) return "알 수 없음"; @@ -192,13 +234,37 @@ export default function MainContent({ ) : (
{searchResults.map((track) => ( -
+
+ + {/* 🌟 썸네일 이미지 영역 (여기 안에 버튼 2개가 떠다닙니다) */}
{track.thumbnail && {track.title}} -
+ + {/* 🌟 [주석으로 제거] 우측 상단: 재생목록 추가 버튼 (위에서 아래로 스르륵) */} + {/* */} + + {/* 🌟 [수정됨] 우측 하단: 바로 재생 버튼 (기존 코드 유지 및 onClick 연결) */} +
+
+

{track.title}

{track.artist}