From 4037afeb680c2faee32d0bee2892b92c7fa40bbc Mon Sep 17 00:00:00 2001 From: tkrmagid-desktop Date: Sat, 11 Apr 2026 14:22:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/src/classes/LavalinkManager.ts | 12 +- bot/src/classes/RedisClient.ts | 14 +- page/next.config.ts | 1 + page/src/app/api/queue/remove/route.ts | 1 - page/src/app/api/queue/set/route.ts | 1 - page/src/app/api/search/route.ts | 7 +- page/src/app/api/servers/route.ts | 2 +- page/src/components/Providers.tsx | 11 +- page/src/components/ToastProvider.tsx | 53 +++++++ page/src/components/layout/TopNav.tsx | 12 -- page/src/components/player/MainContent.tsx | 160 ++++++++++++-------- page/src/components/player/QueueSidebar.tsx | 12 +- 12 files changed, 200 insertions(+), 86 deletions(-) create mode 100644 page/src/components/ToastProvider.tsx diff --git a/bot/src/classes/LavalinkManager.ts b/bot/src/classes/LavalinkManager.ts index d3605e3..7f51c73 100644 --- a/bot/src/classes/LavalinkManager.ts +++ b/bot/src/classes/LavalinkManager.ts @@ -1,4 +1,4 @@ -import { Connectors, LoadType, Shoukaku } from "shoukaku"; +import { Connectors, LoadType, Shoukaku, Track } from "shoukaku"; import { Client } from "discord.js"; import { GuildPlayer } from "./GuildPlayer"; import { Config } from "../utils/Config"; @@ -80,4 +80,14 @@ export class LavalinkManager { return; } } + + public async youtubeSearch(query: string): Promise { + const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); + if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`); + const result = await node.rest.resolve(`ytsearch:${query.trim()}`); + // if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) return []; + if (result?.loadType === LoadType.TRACK) return [ result.data ]; + if (result?.loadType === LoadType.SEARCH) return result.data; + return []; + } } \ No newline at end of file diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index afa1c51..cc5b421 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -8,6 +8,7 @@ import { getGuildById, getVoiceChannelById } from "../utils/music/Channel"; import { channelJoin } from "../commands/join"; import { GuildPlayer } from "./GuildPlayer"; import { Guild, VoiceChannel } from "discord.js"; +import { SongItem } from "../types/Track"; type SubAction = "search" | @@ -47,8 +48,17 @@ export class RedisClient { if (data.action === "search") { 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)); + const spotify: SongItem[] = (await Spotify.getSearchFull(data.query) ?? []).slice(0,10); + const youtubeMusic: SongItem[] = (await YoutubeMusic.getSearchFull(data.query) ?? []).slice(0,10); + const youtubeVideo: SongItem[] = (await lavalinkManager.youtubeSearch(data.query) ?? []).slice(0,10).map((video) => ({ + videoId: video.info.identifier, + url: `https://www.youtube.com/watch?v=${video.info.identifier}`, + title: video.info.title, + artist: video.info.author, + 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})`); } if (data.action === "player_play") { diff --git a/page/next.config.ts b/page/next.config.ts index ec61773..5505fcd 100644 --- a/page/next.config.ts +++ b/page/next.config.ts @@ -5,6 +5,7 @@ const nextConfig: NextConfig = { allowedDevOrigins: [ "192.168.10.13", "localhost", + "music.tkrmagid.kr" ] }; diff --git a/page/src/app/api/queue/remove/route.ts b/page/src/app/api/queue/remove/route.ts index f24e24d..c91a2a6 100644 --- a/page/src/app/api/queue/remove/route.ts +++ b/page/src/app/api/queue/remove/route.ts @@ -30,7 +30,6 @@ export async function POST(request: Request) { // Redis 게시판 확인 const resultData = await Redis.get(resultKey); - console.log(resultData); if (resultData) { // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 diff --git a/page/src/app/api/queue/set/route.ts b/page/src/app/api/queue/set/route.ts index ce6fb57..c517dba 100644 --- a/page/src/app/api/queue/set/route.ts +++ b/page/src/app/api/queue/set/route.ts @@ -30,7 +30,6 @@ export async function POST(request: Request) { // Redis 게시판 확인 const resultData = await Redis.get(resultKey); - console.log(resultData); if (resultData) { // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 diff --git a/page/src/app/api/search/route.ts b/page/src/app/api/search/route.ts index 77d4de4..7793e07 100644 --- a/page/src/app/api/search/route.ts +++ b/page/src/app/api/search/route.ts @@ -23,14 +23,13 @@ export async function GET(request: Request) { })); // 4. 결과가 올라올 때까지 기다리기 (Polling) - // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. + // 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다. for (let i=0; i<10; i++) { - // 0.5초 대기 - await new Promise(resolve => setTimeout(resolve, 500)); + // 1.0초 대기 + await new Promise(resolve => setTimeout(resolve, 1000)); // Redis 게시판 확인 const resultData = await Redis.get(resultKey); - console.log(resultData); if (resultData) { // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 diff --git a/page/src/app/api/servers/route.ts b/page/src/app/api/servers/route.ts index ae49821..2a3892c 100644 --- a/page/src/app/api/servers/route.ts +++ b/page/src/app/api/servers/route.ts @@ -15,7 +15,7 @@ export async function GET() { const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", { headers: { Authorization: `Bearer ${session.accessToken}` }, }); - const userGuilds = await userGuildsRes.json(); + const userGuilds = await userGuildsRes.json() ?? []; // 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기 const botGuildsData = await Redis.get("bot-guilds"); diff --git a/page/src/components/Providers.tsx b/page/src/components/Providers.tsx index 6911d03..d1f1f47 100644 --- a/page/src/components/Providers.tsx +++ b/page/src/components/Providers.tsx @@ -1,7 +1,16 @@ "use client"; import { SessionProvider } from "next-auth/react"; +// 👇 방금 만든 토스트 프로바이더를 불러옵니다. +import { ToastProvider } from "@/components/ToastProvider"; export default function Providers({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {/* 👇 세션 프로바이더 안쪽에 토스트 프로바이더를 감싸줍니다. */} + + {children} + + + ); } \ No newline at end of file diff --git a/page/src/components/ToastProvider.tsx b/page/src/components/ToastProvider.tsx new file mode 100644 index 0000000..74e555d --- /dev/null +++ b/page/src/components/ToastProvider.tsx @@ -0,0 +1,53 @@ +"use client"; +import React, { createContext, useContext, useState, ReactNode } from "react"; + +// 1. 토스트 타입 정의 +interface Toast { + message: string; + type: "success" | "error"; +} + +interface ToastContextType { + showToast: (message: string, type: "success" | "error") => void; +} + +// 2. Context 생성 +const ToastContext = createContext(undefined); + +// 3. 🌟 토스트 UI를 포함한 Provider 컴포넌트 +export function ToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState(null); + + const showToast = (message: string, type: "success" | "error") => { + setToast({ message, type }); + setTimeout(() => { + setToast(null); + }, 3000); + }; + + return ( + + {children} + + {/* 화면 최상단에 떠있는 실제 토스트 UI */} + {toast && ( +
+ {toast.message} +
+ )} +
+ ); +} + +// 4. 컴포넌트에서 쉽게 꺼내 쓸 수 있는 훅 +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast는 ToastProvider 안에서만 사용할 수 있습니다."); + } + return context; +}; \ No newline at end of file diff --git a/page/src/components/layout/TopNav.tsx b/page/src/components/layout/TopNav.tsx index 35520a1..179a3bf 100644 --- a/page/src/components/layout/TopNav.tsx +++ b/page/src/components/layout/TopNav.tsx @@ -13,18 +13,6 @@ export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps const { data: session, status } = useSession(); const [term, setTerm] = useState(""); - const handleSearchClick = () => { - // 🌟 서버 선택 여부 체크 - if (!selectedServer) { - alert("먼저 왼쪽 목록에서 관리할 서버를 선택해주세요!"); - return; - } - - if (term.trim()) { - onSearch(term); - } - }; - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && term.trim()) { onSearch(term); diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index c7933cd..cebb1b3 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; -// 🌟 [수정됨] 상단 아이콘에 쓸 ListPlus 추가 -import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react"; +import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react"; import { ViewMode } from "@/app/page"; +// 🌟 [추가됨] 전역 토스트 훅 불러오기 +import { useToast } from "@/components/ToastProvider"; interface MainContentProps { viewMode: ViewMode; @@ -15,6 +16,12 @@ interface MainContentProps { onSelectServer: (server: any) => void; } +interface SearchResultsType { + spotify: any[]; + youtubeMusic: any[]; + youtubeVideo: any[]; +} + export default function MainContent({ viewMode, setViewMode, @@ -25,19 +32,27 @@ export default function MainContent({ onSelectServer, }: MainContentProps) { const { data: session, status } = useSession(); + + // 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기 + const { showToast } = useToast(); + const [servers, setServers] = useState([]); const [isFetching, setIsFetching] = useState(false); - const [searchResults, setSearchResults] = useState([]); + + const [searchResults, setSearchResults] = useState({ + spotify: [], + youtubeMusic: [], + youtubeVideo: [] + }); const [isSearching, setIsSearching] = useState(false); - // 🌟 [추가됨] 봇에게 재생 및 대기열 추가 명령을 내리는 함수 const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => { if (!selectedServer) { - alert("명령을 내릴 디스코드 서버가 선택되지 않았습니다."); + // 🌟 alert -> showToast 교체 + showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error"); return; } - // 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기 const userId = (session?.user as any)?.id; let endpoint = ""; @@ -60,17 +75,19 @@ export default function MainContent({ const data = await res.json(); if (res.ok && data.success) { - // alert(data.message || "요청이 성공했습니다."); + // 🌟 성공 시 초록색 알림 + showToast("재생목록에 곡을 추가했습니다!", "success"); } else { - alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + // 🌟 에러 시 빨간색 알림 + showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error"); } } catch (error) { console.error("Music Action Error:", error); - alert('서버와 통신할 수 없습니다.'); + // 🌟 네트워크 에러 시 알림 + showToast('서버와 통신할 수 없습니다.', "error"); } }; - // 권한 계산 함수 const getPermissionLabel = (server: any) => { if (!server) return "알 수 없음"; if (server.owner) return "👑 서버 주인"; @@ -84,7 +101,6 @@ export default function MainContent({ } }; - // 1. 서버 목록 불러오기 useEffect(() => { if (status === "authenticated") { setIsFetching(true); @@ -106,17 +122,20 @@ export default function MainContent({ } }, [status]); - // 2. 검색 결과 불러오기 useEffect(() => { if (viewMode === "SEARCH_RESULT" && searchQuery) { setIsSearching(true); - setSearchResults([]); + setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] }); fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`) .then(async (res) => { const data = await res.json(); - if (res.ok && Array.isArray(data)) { - setSearchResults(data); + if (res.ok && data) { + setSearchResults({ + spotify: data.spotify || [], + youtubeMusic: data.youtubeMusic || [], + youtubeVideo: data.youtubeVideo || [] + }); } else { console.error("Search Error:", data); } @@ -126,7 +145,6 @@ export default function MainContent({ } }, [viewMode, searchQuery]); - // 로그인 안 된 상태 if (status === "unauthenticated") { return (
@@ -137,9 +155,30 @@ export default function MainContent({ ); } - // 세션 로딩 중 if (status === "loading") return
; + const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0; + + const renderTrackCard = (track: any) => ( +
+
+ {track.thumbnail && {track.title}} + +
+

{track.title}

+

{track.artist}

+
+ ); + return (
@@ -153,19 +192,21 @@ export default function MainContent({

서버 목록을 불러오는 중...

) : ( -
+
{servers.map((server) => (
onSelectServer(server)} - className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md" + className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md w-full" > -
+
{server.icon ? ( - ) : } + ) : ( + + )}
-

{server.name}

+

{server.name}

))}
@@ -214,60 +255,61 @@ export default function MainContent({
)} - {/* 화면 3: 검색 결과 목록 */} + {/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */} {viewMode === "SEARCH_RESULT" && (

"{searchQuery}" 검색 결과

-

봇을 통해 찾은 유튜브 검색 결과입니다.

+

입력하신 검색어에 대한 플랫폼별 결과입니다.

{isSearching ? (

노래를 찾는 중입니다...

- ) : searchResults.length === 0 ? ( + ) : !hasAnyResults ? (

검색 결과가 없습니다.

) : ( -
- {searchResults.map((track) => ( -
+
- {/* 🌟 썸네일 이미지 영역 (여기 안에 버튼 2개가 떠다닙니다) */} -
- {track.thumbnail && {track.title}} - - {/* 🌟 [주석으로 제거] 우측 상단: 재생목록 추가 버튼 (위에서 아래로 스르륵) */} - {/* */} - - {/* 🌟 [수정됨] 우측 하단: 바로 재생 버튼 (기존 코드 유지 및 onClick 연결) */} - + {/* 카테고리 1: Spotify */} + {searchResults.spotify.length > 0 && ( +
+

+ Spotify 결과 +

+
+ {searchResults.spotify.map(renderTrackCard)}
+
+ )} + + {/* 카테고리 2: YouTube Music */} + {searchResults.youtubeMusic.length > 0 && ( +
+

+ YouTube Music +

+
+ {searchResults.youtubeMusic.map(renderTrackCard)} +
+
+ )} + + {/* 카테고리 3: YouTube Video */} + {searchResults.youtubeVideo.length > 0 && ( +
+

+ YouTube 영상 +

+
+ {searchResults.youtubeVideo.map(renderTrackCard)} +
+
+ )} -

{track.title}

-

{track.artist}

-
- ))}
)}
diff --git a/page/src/components/player/QueueSidebar.tsx b/page/src/components/player/QueueSidebar.tsx index 7f97db6..9d5fde9 100644 --- a/page/src/components/player/QueueSidebar.tsx +++ b/page/src/components/player/QueueSidebar.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { Trash2, GripVertical, Music } from "lucide-react"; import { useSession } from "next-auth/react"; +import { useToast } from "@/components/ToastProvider"; interface QueueSidebarProps { selectedServer: any; @@ -10,6 +11,9 @@ interface QueueSidebarProps { export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { const { data: session, status } = useSession(); + // 👇 [추가] 토스트 사용 준비 완료! + const { showToast } = useToast(); + const [queue, setQueue] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -123,12 +127,12 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { }); const data = await res.json(); if (!res.ok || !data.success) { - alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error"); fetchQueue(); // 실패 시 원래 큐로 복구 } } catch (error) { console.error("순서 동기화 실패:", error); - alert("순서 변경을 서버에 적용하지 못했습니다."); + showToast("순서 변경을 서버에 적용하지 못했습니다.", "error"); fetchQueue(); } }; @@ -152,12 +156,12 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { }); const data = await res.json(); if (!res.ok || !data.success) { - alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error"); fetchQueue(); } } catch (error) { console.error("삭제 실패:", error); - alert("노래를 삭제하지 못했습니다."); + showToast("노래를 삭제하지 못했습니다.", "error"); fetchQueue(); } };