안녕하세요! 혹시 나만의 디스코드 음악 봇을 갖고 싶다는 생각, 한 번쯤 해보셨나요? 이 가이드를 통해 Python과 discord.py
라이브러리를 활용해 강력한 음악 봇을 뚝딱 만들어낼 수 있습니다. 특히 yt-dlp
라이브러리와 YouTube 쿠키를 사용해서 완성된 봇을 디스호스트 플랫폼에 안정적으로 호스팅하는 방법까지 자세히 설명드릴게요.
음악 봇을 직접 만들고, 호스팅하면 다음과 같은 이점이 있어요!
- 끊김 없는 재생: 봇이 차단되거나 사라질 걱정 없이, 안정적으로 음악봇을 이용할 수 있어요.
- 두 채널 이상 동시 재생: 음악봇을 여러 개 호스팅하여, 두 개 채널 이상에서 동시에 음악을 재생할 수 있어요.
- 확장성: 기본 음악 기능 외에도 다양한 명령어를 추가하여 봇을 확장할 수 있어요.
제공되는 bot.py
예제 코드를 중심으로 차근차근 설명해 드릴 테니, 코딩이 처음이시거나 Python이 익숙하지 않으셔도 너무 걱정하지 마세요. 이 코드를 잘 활용하면 디스코드 서버에서 음악을 스트리밍하고, 다양한 명령어를 통해 사용자와 소통하는 멋진 봇을 만들 수 있습니다.
필수 준비물
본 가이드를 진행하기 위해 다음의 준비물이 필요합니다:
- Python 3.8 이상 버전: Python 공식 웹사이트에서 설치할 수 있습니다. Discord.py는 Python 3.8 이상에서만 작동하기 때문에, 이 버전 이상이 필요합니다.
- Discord 계정 및 서버: 봇을 테스트하고 운영할 Discord 계정과 개인 서버가 필요합니다. 서버는 직접 생성하거나 관리자 권한이 있는 기존 서버를 사용할 수 있습니다.
- 텍스트 편집기 또는 IDE: 코드를 작성하고 수정하기 위한 도구입니다. Visual Studio Code를 추천드립니다.
- yt-dlp 및 FFmpeg:
yt-dlp
는 YouTube 및 기타 사이트에서 오디오/비디오 정보를 추출하는 데 사용됩니다. - YouTube 쿠키 파일 (
cookies.txt
): 원격 서버에서 Youtube에 접속하기 위해 필요합니다. - 디스호스트 계정: 봇을 24시간 안정적으로 호스팅하기 위해 필요합니다.
Discord 봇 생성 및 설정
Discord 봇을 운영하기 위해서는 먼저 Discord 개발자 포털에서 애플리케이션을 생성하고 봇 사용자를 설정해야 합니다.
아래 글을 통해 Discord 봇을 생성하는 방법을 자세히 알아보세요.
쿠키 추출 방법
YouTube와 같은 사이트에서 로그인 필요한 콘텐츠나 연령 제한이 있는 콘텐츠를 재생하기 위해서는 쿠키가 필요합니다. 파이어폭스 브라우저와 cookies.txt
확장 프로그램을 사용하여 쿠키를 추출할 수 있습니다. (크롬 브라우저에서는 쿠키 추출이 잘 되지 않는 것을 확인했습니다.)
- 파이어폭스 브라우저를 열고, cookies.txt 확장 프로그램을 설치합니다.
- YouTube에 로그인합니다. (로그인 상태여야 합니다.)
- https://www.youtube.com/robot.txt 페이지로 이동합니다.
- 확장 프로그램 아이콘을 클릭하고 "Current container" 버튼을 눌러
cookies.txt
파일을 다운로드합니다.
이제 추출한 쿠키를 사용할 준비가 되었습니다. 이 쿠키는 나중에 봇 코드를 작성할 때 필요합니다.
봇 코드 작성
이제 bot.py
파일의 전체 코드를 단계별로 살펴보겠습니다. 이 설명을 따라하면 여러분도 직접 음악 봇 코드를 작성할 수 있을 거예요. 걱정 마세요! 코드가 길어 보일 수 있지만, 각 부분이 어떤 역할을 하는지 이해하면 금방 익숙해질 겁니다. 전체 코드는 맨 마지막에 첨부되어 있으니, 안심하시고 천천히 따라서 작성하세요!
1. 필요한 라이브러리 가져오기 (Imports)
# filepath: /Users/kochanhyun/Documents/GitHub/discordjs_tutorial/bot.py
import discord
from discord import app_commands
from discord.ext import commands
import os
import asyncio
import yt_dlp
import logging
from dotenv import load_dotenv
from typing import Dict, List, Optional, Any, Union
코드의 시작은 언제나 필요한 도구들을 챙기는 것과 같아요. 각 라이브러리가 하는 일은 다음과 같습니다:
discord
,discord.app_commands
,discord.ext.commands
:discord.py
라이브러리의 핵심 부분들입니다. 봇을 만들고, 슬래시 명령어를 처리하고, 봇의 기능을 확장하는 데 사용돼요.os
: 운영체제와 상호작용할 때 필요합니다. 주로 환경 변수에서 봇 토큰 같은 설정을 읽어올 때 사용해요.asyncio
: 비동기 프로그래밍을 위한 라이브러리입니다. 여러 작업을 동시에 처리해야 하는 봇에게 필수적이죠. 예를 들어, 음악을 재생하면서 다른 명령어도 받아야 하니까요.yt_dlp
: YouTube를 포함한 다양한 웹사이트에서 오디오/비디오 정보를 가져오고 스트리밍 URL을 추출하는 데 사용됩니다. 이 봇의 핵심 기능인 음악 재생을 담당해요.logging
: 봇이 작동하면서 어떤 일이 일어나는지 기록(로그)을 남기는 데 사용됩니다. 문제가 생겼을 때 원인을 찾거나, 봇의 상태를 확인할 때 유용해요.dotenv
:.env
파일에서 환경 변수를 불러오는 데 사용됩니다. 봇 토큰처럼 민감한 정보를 코드에 직접 적는 대신, 별도의 파일에 안전하게 보관할 수 있게 해줘요.typing
: 타입 힌트를 제공하여 코드의 가독성을 높이고, 개발 중에 발생할 수 있는 오류를 줄이는 데 도움을 줍니다.Dict
,List
등이 그 예시입니다.
2. 로깅 및 환경 변수 설정
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('디스호스트_musicbot')
# .env 파일 로드 (Discord 토큰 등 환경변수)
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')
- 로깅 설정:
logging.basicConfig(...)
는 로그 메시지의 형식과 레벨을 설정합니다.INFO
레벨 이상의 모든 로그가 지정된 형식으로 출력될 거예요.logger = logging.getLogger(...)
는 이 봇을 위한 전용 로거를 만듭니다. - .env 파일 로드:
load_dotenv()
함수는 프로젝트 루트 디렉터리에 있는.env
파일을 찾아 그 안의 변수들을 환경 변수로 로드합니다. TOKEN = os.getenv('DISCORD_TOKEN')
: 로드된 환경 변수 중에서DISCORD_TOKEN
이라는 이름의 값을 가져와TOKEN
변수에 저장합니다. 이 토큰이 바로 여러분의 봇을 Discord 서버에 연결해주는 열쇠입니다!
3. 봇 기본 설정 (Intents 및 Bot 객체 생성)
# 봇 설정
intents = discord.Intents.default()
intents.message_content = True # 메시지 내용 읽기 권한
intents.voice_states = True # 음성 상태 추적 권한
# 기본 help 명령어 비활성화
bot = commands.Bot(command_prefix='/', intents=intents, help_command=None)
intents
: 봇이 Discord로부터 어떤 종류의 이벤트 알림을 받을지 정하는 '의도'입니다.discord.Intents.default()
: 기본적인 인텐트들을 가져옵니다.intents.message_content = True
: 봇이 메시지 내용을 읽을 수 있도록 허용합니다. (예전에는 기본이었지만, 이제는 명시적으로 켜줘야 해요!)intents.voice_states = True
: 사용자가 음성 채널에 들어오거나 나가는 등의 음성 상태 변경을 감지할 수 있게 합니다. 음악 봇에게는 필수죠!
bot = commands.Bot(...)
: 실제 봇 객체를 생성합니다.command_prefix='/'
: 명령어 앞에 붙는 접두사를 설정합니다. 여기서는 슬래시 명령어(/play
등)를 주로 사용하므로, 텍스트 기반 명령어 접두사도/
로 설정했지만, 슬래시 명령어 시스템에서는 이command_prefix
가 직접적으로 사용되지는 않습니다. (하단에 텍스트 명령어 호환성 부분에서 사용됩니다.)intents=intents
: 위에서 설정한 인텐트를 봇에 적용합니다.help_command=None
:discord.py
가 기본으로 제공하는help
명령어를 비활성화합니다. 우리는 직접 커스텀/help
명령어를 만들 거예요.
4. YT-DLP 및 FFmpeg 옵션 설정
# YT-DLP 설정 (쿠키 기반)
ytdlp_format_options = {
'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best',
'no_playlist': True,
'noplaylist': True,
'quiet': True,
'no_warnings': True,
'default_search': 'auto',
'source_address': '0.0.0.0', # IPv4 강제 또는 특정 IP 사용 시
'geo_bypass': True,
'extractor_retries': 3,
'nocheckcertificate': True,
'age_limit': 99,
'extract_flat': False,
'ignoreerrors': True,
'cookiefile': 'cookies.txt', # 쿠키 파일 사용
'http_headers': { # 필요시 User-Agent 등 헤더 설정
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Encoding': 'gzip,deflate',
'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
'Keep-Alive': '300',
'Connection': 'keep-alive',
},
}
ffmpeg_options = {
'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
'options': '-vn -filter:a "volume=0.5"' # 오디오 볼륨 50%로 설정
}
이 부분은 음악 재생의 핵심 설정입니다.
ytdlp_format_options
:yt-dlp
가 YouTube (또는 다른 사이트)에서 정보를 가져올 때 사용할 옵션들입니다.'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best'
: 오디오 품질을 최상으로 설정합니다. webm 형식을 우선으로 하되, 없으면 다른 최상 품질 오디오를 선택합니다. 기본적으로 유튜브 영상은 webm 형식으로 제공되므로, webm 형식을 사용하면 ffmpeg로 변환할 필요가 없습니다.'no_playlist': True, 'noplaylist': True
: 재생목록 URL을 주더라도 단일 영상만 처리하도록 합니다.'quiet': True, 'no_warnings': True
:yt-dlp
가 터미널에 내보내는 메시지를 최소화합니다.'default_search': 'auto'
: 검색어를 입력하면 자동으로 YouTube에서 검색합니다 (ytsearch:
).'source_address': '0.0.0.0'
: 특정 IP 주소를 사용하도록 강제할 수 있습니다. (네트워크 환경에 따라 필요할 수 있음)'geo_bypass': True
: 지역 제한을 우회하려고 시도합니다.'cookiefile': 'cookies.txt'
: 매우 중요! 이 부분이 바로 쿠키 파일을 사용하도록 설정하는 곳입니다. 프로젝트 루트 디렉터리에cookies.txt
라는 이름으로 저장된 쿠키 파일을yt-dlp
가 사용하게 됩니다. 이렇게 하면 연령 제한 콘텐츠나 로그인 필요한 콘텐츠에 접근할 가능성이 높아집니다.'http_headers'
: 요청 시 사용할 HTTP 헤더입니다. 때때로 특정 User-Agent가 필요할 수 있습니다.
ffmpeg_options
: FFmpeg는 오디오/비디오를 처리하는 강력한 도구입니다.discord.py
는 내부적으로 FFmpeg를 사용하여 오디오를 Discord 음성 채널로 스트리밍합니다.'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5'
: 네트워크 연결이 불안정할 때 스트림 재연결을 시도하는 옵션입니다.'options': '-vn -filter:a "volume=0.5"'
:-vn
은 비디오를 사용하지 않음(오디오만)을 의미하고,-filter:a "volume=0.5"
는 오디오 볼륨을 50%로 줄입니다. (기본 볼륨이 너무 클 수 있어서 조절)
5. 전역 변수 (Global Variables)
# 전역 변수들
music_queues: Dict[int, List[Dict[str, Any]]] = {}
voice_clients: Dict[int, discord.VoiceClient] = {}
current_songs: Dict[int, Dict[str, Any]] = {}
loop_modes: Dict[int, int] = {} # 0: 반복없음, 1: 현재곡 반복, 2: 큐 반복
봇이 여러 서버(길드)에서 동시에 작동할 수 있도록, 각 서버별로 음악 큐, 음성 연결 상태, 현재 재생 곡, 반복 모드 등을 저장해야 합니다. 이 딕셔너리들이 그 역할을 합니다.
music_queues
: 각 서버(길드 ID가 key)의 음악 대기열(재생할 노래 목록)을 저장합니다.Dict[int, List[Dict[str, Any]]]
는 "길드 ID(정수)를 키로 하고, 노래 정보 딕셔너리들의 리스트를 값으로 하는 딕셔너리"라는 뜻입니다.- 노래 정보 딕셔너리에는
'title'
,'url'
,'duration'
등이 들어갑니다.
voice_clients
: 각 서버의 음성 연결 객체(discord.VoiceClient
)를 저장합니다. 봇이 어떤 음성 채널에 연결되어 있는지 관리합니다.current_songs
: 각 서버에서 현재 재생 중인 노래 정보를 저장합니다.loop_modes
: 각 서버의 반복 모드를 저장합니다. (0: 반복 없음, 1: 현재 곡 반복, 2: 큐 전체 반복)
6. UI 버튼을 위한 MusicView
클래스
class MusicView(discord.ui.View):
def __init__(self, guild_id: int):
super().__init__(timeout=None) # timeout=None으로 버튼 영구 활성화
self.guild_id = guild_id
@discord.ui.button(label='⏸️ 일시정지', style=discord.ButtonStyle.secondary)
async def pause_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer() # 응답 대기
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
voice_clients[self.guild_id].pause()
await interaction.followup.send("⏸️ 음악이 일시정지되었습니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 재생 중인 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='▶️ 재생', style=discord.ButtonStyle.secondary)
async def resume_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_paused():
voice_clients[self.guild_id].resume()
await interaction.followup.send("▶️ 음악이 재개되었습니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 일시정지된 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='⏭️ 스킵', style=discord.ButtonStyle.secondary)
async def skip_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
voice_clients[self.guild_id].stop() # stop()을 호출하면 after 콜백(play_next)이 실행됨
await interaction.followup.send("⏭️ 다음 곡으로 스킵합니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 재생 중인 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='⏹️ 정지', style=discord.ButtonStyle.danger)
async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in music_queues:
music_queues[self.guild_id].clear()
if self.guild_id in voice_clients:
voice_clients[self.guild_id].stop()
if self.guild_id in current_songs:
del current_songs[self.guild_id]
await interaction.followup.send("⏹️ 음악이 정지되고 큐가 초기화되었습니다.", ephemeral=True)
Discord의 버튼 UI를 만들기 위한 클래스입니다. discord.ui.View
를 상속받아 만들어요.
__init__(self, guild_id: int)
: 뷰가 생성될 때 어떤 서버에 속한 뷰인지guild_id
를 저장합니다.super().__init__(timeout=None)
은 버튼이 특정 시간 후에 비활성화되지 않고 계속 작동하도록 합니다.@discord.ui.button(...)
: 각 버튼을 정의하는 데코레이터입니다.label
: 버튼에 표시될 텍스트 (이모티콘 포함 가능)style
: 버튼의 색상과 모양 (예:discord.ButtonStyle.secondary
는 회색,danger
는 빨간색)
- 각 버튼 함수 (
pause_button
,resume_button
등):interaction: discord.Interaction
: 사용자가 버튼을 눌렀을 때 발생하는 상호작용 객체입니다.await interaction.response.defer()
: "일단 알겠어, 처리 중이니 잠시만 기다려줘!" 라는 의미입니다. 바로 응답하지 않으면 상호작용이 실패 처리될 수 있어서 필요해요.ephemeral=True
: 이 옵션을 사용하면 버튼 클릭 결과 메시지가 버튼을 누른 사용자에게만 보이게 됩니다. 채팅창을 깔끔하게 유지하는 데 도움이 되죠.- 나머지 로직은 각 버튼의 기능(일시정지, 재개, 스킵, 정지)을 수행합니다. 전역 변수인
voice_clients
,music_queues
등을 사용하여 해당 서버의 상태를 변경합니다. - 스킵 버튼의 경우
voice_clients[self.guild_id].stop()
을 호출하면,play_song
함수에서after
콜백으로 등록된play_next
함수가 자동으로 실행되어 다음 곡을 재생하게 됩니다.
7. 헬퍼 함수 (Helper Functions)
자주 사용되는 기능들을 별도의 함수로 만들어두면 코드가 깔끔해지고 재사용하기 좋습니다.
get_queue_embed(guild_id: int)
def get_queue_embed(guild_id: int) -> discord.Embed:
"""현재 큐 상태를 임베드로 생성"""
embed = discord.Embed(title="🎵 음악 큐", color=0x3498db)
if guild_id in current_songs:
current = current_songs[guild_id]
embed.add_field(
name="🎶 현재 재생 중",
value=f"**{current['title']}**\n⏱️ {current.get('duration', 'Unknown')}\n🎤 {current.get('uploader', 'Unknown')}",
inline=False
)
if guild_id in music_queues and music_queues[guild_id]:
queue_text = ""
for i, song in enumerate(music_queues[guild_id][:5]): # 최대 5개만 표시
queue_text += f"{i+1}. **{song['title']}** ({song.get('duration', 'Unknown')})\n"
if len(music_queues[guild_id]) > 5:
queue_text += f"... 그리고 {len(music_queues[guild_id]) - 5}곡 더"
embed.add_field(name="📋 대기열", value=queue_text, inline=False)
else:
embed.add_field(name="📋 대기열", value="비어있음", inline=False)
# 반복 모드 표시
loop_mode = loop_modes.get(guild_id, 0) # guild_id가 없으면 기본값 0 (반복 없음)
loop_text = ["🔁 반복 없음", "🔂 현재 곡 반복", "🔁 큐 반복"][loop_mode]
embed.add_field(name="🔄 반복 모드", value=loop_text, inline=True)
embed.set_footer(text="YT-DLP (쿠키) • 디스호스트 음악봇 v2.0")
return embed
현재 음악 큐 상태를 보기 좋게 Discord Embed 메시지로 만들어 반환합니다.
discord.Embed
: Discord에서 사용하는 특별한 형식의 메시지입니다. 제목, 설명, 필드, 색상, 푸터 등을 설정할 수 있어요.- 현재 재생 중인 곡, 대기열 (최대 5곡 표시), 반복 모드 정보를 담아서 보여줍니다.
.get('duration', 'Unknown')
: 딕셔너리에서 값을 가져올 때, 해당 키가 없으면 기본값('Unknown')을 사용하도록 합니다. 프로그램이 오류로 멈추는 것을 방지해줘요.
search_youtube(query: str)
async def search_youtube(query: str) -> Optional[Dict[str, Any]]:
"""YouTube에서 검색 (쿠키 기반)"""
try:
with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
# URL이면 직접 처리, 검색어면 ytsearch: 접두사 사용
if query.startswith(('http://', 'https://')):
search_query = query
else:
search_query = f"ytsearch:{query}" # ytsearch: 뒤에 검색어를 붙이면 유튜브에서 검색
info = ydl.extract_info(search_query, download=False) # download=False는 실제 다운로드는 안 함
if not info:
return None
# 검색 결과에서 첫 번째 항목 가져오기
if 'entries' in info and info['entries']: # 재생목록 검색 결과 처리
video_info = info['entries'][0]
else: # 단일 영상 결과 처리
video_info = info
return {
'title': video_info.get('title', 'Unknown Title'),
'url': video_info.get('webpage_url', video_info.get('url', '')), # 웹페이지 URL 우선
'duration': str(video_info.get('duration_string', video_info.get('duration', 'Unknown'))), # 초 단위보다는 문자열 형식 우선
'uploader': video_info.get('uploader', 'Unknown'),
'thumbnail': video_info.get('thumbnail', ''),
'view_count': video_info.get('view_count', 0)
}
except Exception as e:
logger.error(f"YouTube 검색 중 오류: {e}")
return None
사용자가 입력한 검색어(또는 URL)를 바탕으로 yt-dlp
를 사용해 YouTube에서 노래 정보를 검색합니다.
query.startswith(('http://', 'https://'))
: 입력이 URL인지 단순 검색어인지 확인합니다.search_query = f"ytsearch:{query}"
: 단순 검색어면ytsearch:
접두사를 붙여yt-dlp
가 YouTube에서 검색하도록 합니다.ydl.extract_info(search_query, download=False)
: 실제 오디오를 다운로드하지 않고, 메타데이터(제목, URL, 길이 등)만 가져옵니다.- 결과에서 필요한 정보(제목, 원본 URL, 길이, 업로더, 썸네일 등)를 추출하여 딕셔너리 형태로 반환합니다.
duration_string
이 있으면 그걸 쓰고, 없으면duration
(초 단위 숫자)을 문자열로 바꿔 씁니다.duration_string
이 "3:15"처럼 보기 좋은 형태이기 때문입니다.
play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int)
async def play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int):
"""YT-DLP를 사용하여 음악 재생 (쿠키 기반)"""
try:
# YT-DLP로 스트림 URL 추출 (한 번 더 호출하여 최신 스트림 URL 확보)
with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
# song_info['url']은 webpage_url이므로, 이걸로 다시 extract_info를 호출해야 스트리밍 URL을 얻음
info = ydl.extract_info(song_info['url'], download=False)
if not info:
logger.error("YT-DLP에서 정보를 가져올 수 없습니다 (play_song)")
# 다음 곡 시도 또는 오류 메시지
await play_next(guild_id)
return False
# 포맷 중에서 실제 오디오 스트림 URL 찾기
# 때로는 info['url']에 바로 있기도 하고, formats 안에 있기도 함
stream_url = None
if 'url' in info: # 직접 URL이 있는 경우
stream_url = info['url']
elif 'formats' in info: # formats 리스트에서 찾아야 하는 경우
for f in reversed(info['formats']): # 고화질 오디오가 뒤에 있는 경우가 많음
if f.get('acodec') != 'none' and f.get('vcodec') == 'none': # 오디오 코덱 있고 비디오 코덱 없는 것
if f.get('url'):
stream_url = f['url']
break
if not stream_url: # 못찾았으면 그냥 첫번째 format의 url (최후의 수단)
stream_url = info['formats'][0]['url'] if info['formats'] else None
if not stream_url:
logger.error("스트림 URL을 찾을 수 없습니다 (play_song)")
await play_next(guild_id)
return False
# 음악 재생
source = discord.FFmpegPCMAudio(stream_url, **ffmpeg_options)
voice_client.play(source, after=lambda e: asyncio.run_coroutine_threadsafe(
play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}'))
current_songs[guild_id] = song_info # 현재 재생 곡 정보 업데이트
logger.info(f"재생 시작: {song_info['title']} (서버: {guild_id})")
return True
except Exception as e:
logger.error(f"음악 재생 중 오류 (play_song): {e} - 곡: {song_info.get('title', '알 수 없음')}")
# 오류 발생 시 다음 곡 재생 시도
await play_next(guild_id)
return False
실제로 노래를 재생하는 함수입니다.
search_youtube
에서 얻은song_info
(특히song_info['url']
은 웹페이지 URL)를 사용해yt-dlp
로 다시 한번extract_info
를 호출합니다. 이번에는 실제 오디오 스트림 URL을 얻기 위함입니다. YouTube의 스트림 URL은 시간이 지나면 만료될 수 있어서, 재생 직전에 다시 얻는 것이 안정적입니다.info
딕셔너리에서 실제 스트리밍 가능한 URL (stream_url
)을 찾습니다. 때로는info['url']
에 바로 있기도 하고,info['formats']
리스트 안에서 적절한 오디오 포맷을 찾아야 할 수도 있습니다. 여기서는 오디오 코덱이 있고 비디오 코덱이 없는 포맷을 우선적으로 찾습니다.discord.FFmpegPCMAudio(stream_url, **ffmpeg_options)
: 찾은 스트림 URL과 FFmpeg 옵션을 사용해 오디오 소스를 만듭니다.voice_client.play(source, after=lambda e: ...)
: 음성 클라이언트에서 오디오 소스를 재생합니다.after
: 이 부분이 중요합니다! 노래 재생이 끝나거나,voice_client.stop()
으로 중지되면after
에 지정된 함수가 호출됩니다.lambda e: asyncio.run_coroutine_threadsafe(play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}')
:- 재생 중 오류(
e
)가 없었다면,play_next(guild_id)
함수를 비동기적으로 안전하게 실행하여 다음 곡을 재생합니다. - 오류가 있었다면 로그를 남깁니다.
- 재생 중 오류(
current_songs[guild_id] = song_info
: 현재 재생 중인 곡 정보를 업데이트합니다.
play_next(guild_id: int)
async def play_next(guild_id: int):
"""다음 곡 재생 또는 큐/현재곡 반복 처리"""
try:
if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
logger.info(f"play_next 호출: {guild_id} 음성 클라이언트 없음 또는 연결 끊김. 정리 시도.")
if guild_id in current_songs: del current_songs[guild_id]
if guild_id in music_queues: music_queues[guild_id].clear()
return
voice_client = voice_clients[guild_id]
# 현재 곡 정보 초기화 (재생이 끝났으므로)
# 단, 현재곡 반복 모드가 아닐 때만. 현재곡 반복이면 play_song에서 다시 설정됨.
loop_mode = loop_modes.get(guild_id, 0)
if loop_mode != 1 and guild_id in current_songs: # 현재곡 반복이 아니면 현재곡 정보 삭제
del current_songs[guild_id]
if loop_mode == 1: # 현재 곡 반복
# current_songs에 이전 곡 정보가 남아있어야 함. play_song에서 다시 설정될 것임.
# 하지만 play_song을 호출하기 전까지 current_songs[guild_id]가 남아있도록 보장해야 함.
# -> current_songs는 play_song이 시작 시점에 설정되므로, 여기서 다시 가져올 필요가 없음.
# 하지만 current_songs가 이미 위에서 삭제되었을 수 있으므로,
# current_songs는 play_song이 성공적으로 실행될 때만 업데이트하는 것이 좋음.
# 따라서, 반복 재생할 곡 정보를 임시 변수에 저장해두는 것이 안전.
song_to_replay = None # 임시로 곡 정보를 저장할 변수
# current_songs는 play_song이 설정됨에 따라, play_next에서 참조할 필요가 없음.
# 대신, play_song이 호출되기 전까지 current_songs가 비워지지 않도록 해야함.
# -> current_songs는 play_song이 성공적으로 실행될 때만 업데이트.
# -> 반복 모드일 경우, play_song에 전달할 song_info가 필요.
# 이 song_info는 직전에 재생했던 곡의 정보여야 함.
# current_songs 딕셔너리는 서버별로 현재 "재생 완료된" 또는 "재생 중인" 곡을 가리킴.
# 따라서, play_song을 다시 호출할 때, 이전에 current_songs에 저장했던 값을 사용해야 함.
# current_songs는 play_song이 시작될 때 설정됨.
# play_next는 곡이 "끝난 후" 호출됨.
# 따라서 loop_mode == 1일 때, "방금 끝난 곡"을 다시 재생해야 함.
# 이 정보는 current_songs[guild_id]에 있었어야 함.
# 위의 current_songs 삭제 로직을 loop_mode == 1이 아닐 때만 수행하도록 수정.
if guild_id in current_songs: # 방금 끝난 곡 정보가 남아있다면
song_to_replay_info = current_songs[guild_id]
logger.info(f"현재 곡 반복: {song_to_replay_info['title']} (서버: {guild_id})")
await play_song(voice_client, song_to_replay_info, guild_id)
return # 현재 곡 반복 재생 시작했으므로 함수 종료
else: # 현재 곡 정보가 없으면 (오류 상황이거나 첫 곡 재생 실패 후 등)
logger.warning(f"현재 곡 반복 모드이나, current_songs에 곡 정보 없음 (서버: {guild_id})")
# 그냥 다음 곡으로 넘어가도록 처리 (아래 로직으로 이어짐)
# 큐에서 다음 곡 가져오기
if guild_id in music_queues and music_queues[guild_id]:
next_song_info = music_queues[guild_id].pop(0) # 큐의 맨 앞에서 곡을 꺼냄
if loop_mode == 2: # 큐 반복 모드
music_queues[guild_id].append(next_song_info) # 꺼낸 곡을 다시 큐의 맨 뒤에 추가
logger.info(f"큐 반복: {next_song_info['title']} 다시 큐에 추가 (서버: {guild_id})")
logger.info(f"큐에서 다음 곡 재생: {next_song_info['title']} (서버: {guild_id})")
await play_song(voice_client, next_song_info, guild_id)
else:
# 큐가 비어있으면 (더 이상 재생할 곡이 없으면)
logger.info(f"큐가 비어있음 (서버: {guild_id}). 현재 곡 정보 삭제.")
if guild_id in current_songs: # 확실히 현재 재생 중인 곡이 없도록 정리
del current_songs[guild_id]
# 필요하다면 여기서 음성 채널 자동 퇴장 로직 추가 가능
# 예: await asyncio.sleep(300) # 5분 대기 후
# if guild_id in voice_clients and not voice_clients[guild_id].is_playing() and not music_queues.get(guild_id):
# await voice_clients[guild_id].disconnect()
# del voice_clients[guild_id]
# logger.info(f"음악 없음, {guild_id} 음성 채널 자동 퇴장")
except Exception as e:
logger.error(f"다음 곡 재생 중 오류 (play_next): {e} (서버: {guild_id})")
# 오류 발생 시에도 current_songs 정리 시도
if guild_id in current_songs:
del current_songs[guild_id]
이전 곡 재생이 끝나면 호출되어 다음 곡을 재생하거나 반복 설정을 처리합니다.
- 반복 모드 확인:
loop_modes
를 보고 현재 서버의 반복 설정을 가져옵니다. - 현재 곡 반복 (
loop_mode == 1
):current_songs[guild_id]
에 저장된 (방금 재생이 끝난) 곡 정보를 가져와 다시play_song
을 호출합니다.- 중요:
current_songs
는play_song
이 성공적으로 시작될 때 업데이트됩니다.play_next
는 곡이 끝난 후 호출되므로,loop_mode == 1
일 때는current_songs
에 아직 이전 곡 정보가 남아있어야 합니다. 그래서current_songs
삭제 로직은loop_mode != 1
일 때만 수행됩니다.
- 큐에서 다음 곡 재생:
music_queues[guild_id].pop(0)
: 큐의 맨 앞에 있는 곡을 꺼냅니다.- 큐 반복 (
loop_mode == 2
): 꺼낸 곡을 다시 큐의 맨 뒤에 추가합니다. - 꺼낸 곡 정보로
play_song
을 호출하여 재생합니다.
- 큐가 비었을 때: 더 이상 재생할 곡이 없으면
current_songs
에서 현재 곡 정보를 삭제합니다. (필요하다면 여기서 일정 시간 후 음성 채널 자동 퇴장 같은 기능을 추가할 수 있습니다.)
8. 봇 이벤트 핸들러 (on_ready
)
@bot.event
async def on_ready():
logger.info(f'{bot.user}가 로그인했습니다!')
# 로컬 명령어 확인 (디버깅용)
# local_commands = bot.tree.get_commands()
# logger.info(f"📋 로컬에 정의된 명령어 수: {len(local_commands)}")
# for cmd in local_commands:
# logger.info(f" ✓ {cmd.name}: {cmd.description}")
guild_count = len(bot.guilds)
logger.info(f"🏠 봇이 속한 서버 수: {guild_count}")
# 명령어 동기화 (봇이 시작될 때마다 실행)
# 개발 중에는 길드 명령어로 빠르게 테스트하고, 배포 시에는 글로벌로 전환 고려
# 여기서는 모든 길드에 즉시 적용되도록 길드별 동기화를 사용합니다.
logger.info("🔄 명령어 동기화 시작...")
for guild in bot.guilds:
try:
# 특정 길드에만 명령어 복사 및 동기화
bot.tree.copy_global_to(guild=guild) # 글로벌 명령어를 길드 명령어로 복사
synced = await bot.tree.sync(guild=guild)
logger.info(f"✅ {guild.name} ({guild.id}) 서버에 {len(synced)}개 명령어 동기화 완료.")
except Exception as e:
logger.error(f"❌ {guild.name} ({guild.id}) 서버 명령어 동기화 실패: {e}")
# 만약 글로벌로 동기화하고 싶다면:
# try:
# synced = await bot.tree.sync()
# logger.info(f"🌍 글로벌 명령어 {len(synced)}개 동기화 완료.")
# except Exception as e:
# logger.error(f"❌ 글로벌 명령어 동기화 실패: {e}")
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.listening,
name="음악 | /play" # 봇 상태 메시지
)
)
logger.info("🎶 봇 상태 메시지 설정 완료.")
봇이 준비되고 Discord에 성공적으로 로그인했을 때 실행되는 이벤트입니다.
logger.info(f'{bot.user}가 로그인했습니다!')
: 봇이 어떤 이름으로 로그인했는지 로그를 남깁니다.- 명령어 동기화 (Slash Commands Sync):
- 슬래시 명령어는 Discord에 등록(동기화)되어야 사용할 수 있습니다.
bot.tree.copy_global_to(guild=guild)
: 코드에 정의된 (글로벌 범위의) 슬래시 명령어들을 특정 길드(서버)의 명령어로 복사합니다.await bot.tree.sync(guild=guild)
: 해당 길드에 명령어들을 동기화합니다. 길드 명령어는 즉시 적용되는 장점이 있어 개발 중에 유용합니다.- 주석 처리된 부분은 모든 서버에 적용되는 글로벌 명령어 동기화 방법입니다. 글로벌 명령어는 적용되기까지 최대 1시간이 걸릴 수 있습니다.
await bot.change_presence(...)
: 봇의 상태 메시지(예: "음악 듣는 중 | /play")를 설정합니다.
9. 슬래시 명령어 (Slash Commands)
이제 사용자가 실제로 상호작용할 명령어들을 정의합니다. 모든 슬래시 명령어는 @bot.tree.command(...)
데코레이터를 사용해 만듭니다.
/play [query]
@bot.tree.command(name="play", description="음악을 재생하거나 큐에 추가합니다.")
@app_commands.describe(query="재생할 음악의 제목이나 YouTube URL")
async def play_slash(interaction: discord.Interaction, query: str):
await interaction.response.defer() # 응답 지연
if not interaction.user.voice: # 사용자가 음성 채널에 있는지 확인
await interaction.followup.send("❌ 음성 채널에 먼저 접속해주세요!", ephemeral=True)
return
channel = interaction.user.voice.channel
guild_id = interaction.guild.id
# 봇이 음성 채널에 연결되어 있지 않으면 연결
if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
try:
# 이미 다른 채널에 있다면 이동, 없으면 새로 연결
if guild_id in voice_clients and voice_clients[guild_id].channel != channel:
await voice_clients[guild_id].move_to(channel)
logger.info(f"{guild_id} 음성 채널 이동: {channel.name}")
else:
voice_client = await channel.connect()
voice_clients[guild_id] = voice_client
logger.info(f"{guild_id} 음성 채널 연결: {channel.name}")
except Exception as e:
logger.error(f"음성 채널 연결 실패 ({guild_id}): {e}")
await interaction.followup.send(f"❌ 음성 채널 연결에 실패했어요: {e}", ephemeral=True)
return
# 이미 연결된 voice_client 사용
voice_client = voice_clients[guild_id]
song_info = await search_youtube(query)
if not song_info:
await interaction.followup.send(f"❌ '{query}'에 대한 검색 결과를 찾을 수 없어요.", ephemeral=True)
return
if guild_id not in music_queues:
music_queues[guild_id] = []
# 현재 재생 중인 곡이 없거나, voice_client가 아무것도 재생하고 있지 않으면 바로 재생
if guild_id not in current_songs or not voice_client.is_playing():
# current_songs에 항목이 있어도, 실제로는 재생이 끝났을 수 있음 (play_next에서 정리되기 전)
# 따라서 voice_client.is_playing()도 함께 확인
success = await play_song(voice_client, song_info, guild_id)
if success:
embed = discord.Embed(
title="▶️ 지금 재생",
description=f"**[{song_info['title']}]({song_info['url']})**",
color=0x3498db
)
embed.add_field(name="⏱️ 길이", value=song_info.get('duration', 'N/A'), inline=True)
embed.add_field(name="🎤 요청자", value=interaction.user.mention, inline=True)
if song_info.get('thumbnail'):
embed.set_thumbnail(url=song_info['thumbnail'])
embed.set_footer(text=f"업로더: {song_info.get('uploader', 'N/A')}")
await interaction.followup.send(embed=embed, view=MusicView(guild_id))
else:
await interaction.followup.send(f"❌ '{song_info['title']}' 재생에 실패했어요.", ephemeral=True)
else:
music_queues[guild_id].append(song_info)
embed = discord.Embed(
title="🎵 큐에 추가됨",
description=f"**[{song_info['title']}]({song_info['url']})**",
color=0x9b59b6 # 보라색 계열
)
embed.add_field(name="⏱️ 길이", value=song_info.get('duration', 'N/A'), inline=True)
embed.add_field(name="📋 대기열 순서", value=f"{len(music_queues[guild_id])}번째", inline=True)
if song_info.get('thumbnail'):
embed.set_thumbnail(url=song_info['thumbnail'])
await interaction.followup.send(embed=embed)
음악을 재생하거나 큐에 추가합니다.
await interaction.response.defer()
: 명령어 처리에 시간이 걸릴 수 있으므로, Discord에게 "처리 중"임을 알립니다.ephemeral=True
를 사용하면 이 메시지는 명령어 사용자에게만 보입니다. (여기서는defer
만 하고 실제 응답은followup.send
로 합니다.)- 사용자가 음성 채널에 있는지 확인합니다. 없으면 오류 메시지를 보냅니다.
- 봇을 사용자의 음성 채널에 연결합니다.
- 이미 다른 채널에 연결되어 있다면 그 채널로 이동합니다.
- 아직 연결되어 있지 않다면 새로 연결하고
voice_clients
에 저장합니다.
await search_youtube(query)
: 입력받은query
로 YouTube를 검색합니다.- 검색 결과(
song_info
)가 없으면 오류 메시지를 보냅니다. - 해당 서버의 큐(
music_queues[guild_id]
)가 없으면 새로 만듭니다. - 재생 로직:
- 현재 재생 중인 곡이 없거나(
guild_id not in current_songs
) 또는 음성 클라이언트가 실제로 아무것도 재생하고 있지 않으면(not voice_client.is_playing()
),await play_song(voice_client, song_info, guild_id)
을 호출하여 바로 재생합니다.- 재생 성공 시, 현재 재생 정보를 Embed로 만들어
MusicView
버튼들과 함께 보여줍니다.
- 재생 성공 시, 현재 재생 정보를 Embed로 만들어
- 그렇지 않으면 (이미 뭔가 재생 중이면),
music_queues[guild_id].append(song_info)
로 큐에 추가하고 추가되었다는 Embed 메시지를 보냅니다.
- 현재 재생 중인 곡이 없거나(
/queue
@bot.tree.command(name="queue", description="현재 음악 큐를 보여줍니다.")
async def queue_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
embed = get_queue_embed(guild_id) # 위에서 만든 헬퍼 함수 사용
await interaction.response.send_message(embed=embed, view=MusicView(guild_id), ephemeral=True)
현재 음악 큐를 보여줍니다. get_queue_embed
헬퍼 함수를 사용해 Embed를 만들고, MusicView
버튼들과 함께 전송합니다. ephemeral=True
로 설정하여 명령어 사용자에게만 보이도록 합니다.
/skip
@bot.tree.command(name="skip", description="현재 재생 중인 곡을 건너뜁니다.")
async def skip_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].stop() # stop()은 play_next를 호출하지만, 큐가 비었으므로 아무것도 안 함
await interaction.response.send_message("⏭️ 다음 곡으로 건너뛰었어요.", ephemeral=True)
else:
await interaction.response.send_message("❌ 지금은 건너뛸 곡이 없어요.", ephemeral=True)
현재 곡을 건너뜁니다. voice_clients[guild_id].stop()
을 호출하면 play_song
의 after
콜백으로 등록된 play_next
함수가 실행되어 다음 곡이 재생됩니다.
/pause
@bot.tree.command(name="pause", description="음악 재생을 일시정지합니다.")
async def pause_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].pause()
await interaction.response.send_message("⏸️ 음악을 일시정지했어요.", ephemeral=True)
else:
await interaction.response.send_message("❌ 일시정지할 음악이 없어요.", ephemeral=True)
음악을 일시정지합니다.
/resume
@bot.tree.command(name="resume", description="일시정지된 음악을 다시 재생합니다.")
async def resume_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_paused(): # is_paused()로 확인
voice_clients[guild_id].resume()
await interaction.response.send_message("▶️ 음악을 다시 재생할게요.", ephemeral=True)
else:
await interaction.response.send_message("❌ 다시 재생할 음악이 없는데요?", ephemeral=True)
일시정지된 음악을 다시 재생합니다.
/stop
@bot.tree.command(name="stop", description="음악 재생을 멈추고 큐를 비웁니다.")
async def stop_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
# 큐 비우기
if guild_id in music_queues:
music_queues[guild_id].clear()
# 현재 곡 정보 삭제
if guild_id in current_songs:
del current_songs[guild_id]
# 음성 클라이언트 정지 (재생 중인 것이 있다면)
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].stop() # stop()은 play_next를 호출하지만, 큐가 비었으므로 아무것도 안 함
await interaction.response.send_message("⏹️ 모든 음악을 멈추고 큐를 깨끗하게 비웠어요!", ephemeral=True)
음악 재생을 완전히 멈추고 큐도 비웁니다.
/join
@bot.tree.command(name="join", description="봇을 현재 음성 채널로 불러옵니다.")
async def join_slash(interaction: discord.Interaction):
if not interaction.user.voice:
await interaction.response.send_message("❌ 먼저 음성 채널에 들어가주세요!", ephemeral=True)
return
channel = interaction.user.voice.channel
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_connected():
if voice_clients[guild_id].channel == channel:
await interaction.response.send_message("✅ 이미 여기 있어요!", ephemeral=True)
else:
await voice_clients[guild_id].move_to(channel)
await interaction.response.send_message(f"슝! **{channel.name}** 채널로 이동했어요.", ephemeral=True)
else:
try:
vc = await channel.connect()
voice_clients[guild_id] = vc
await interaction.response.send_message(f"안녕하세요! **{channel.name}** 채널에 왔어요.", ephemeral=True)
except Exception as e:
logger.error(f"음성 채널 참가 실패 ({guild_id}, {channel.name}): {e}")
await interaction.response.send_message(f"❌ 이런, **{channel.name}** 채널에 들어갈 수가 없네요: {e}", ephemeral=True)
봇을 사용자가 있는 음성 채널로 불러옵니다. 이미 다른 채널에 있다면 이동합니다.
/leave
@bot.tree.command(name="leave", description="봇을 음성 채널에서 내보냅니다.")
async def leave_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_connected():
await voice_clients[guild_id].disconnect()
# 연결 해제 후 관련 정보 정리
if guild_id in voice_clients: del voice_clients[guild_id]
if guild_id in music_queues: del music_queues[guild_id] # 큐도 함께 정리
if guild_id in current_songs: del current_songs[guild_id]
if guild_id in loop_modes: del loop_modes[guild_id]
await interaction.response.send_message("👋 안녕히 계세요! 다음에 또 만나요.", ephemeral=True)
else:
await interaction.response.send_message("❌ 저 지금 음성 채널에 없는데요?", ephemeral=True)
봇을 음성 채널에서 내보내고 관련 데이터(큐, 현재 곡 정보 등)를 정리합니다.
/loop
@bot.tree.command(name="loop", description="반복 모드를 설정합니다.")
@app_commands.describe(mode="설정할 반복 모드")
@app_commands.choices(mode=[
app_commands.Choice(name="반복 없음", value=0),
app_commands.Choice(name="현재 곡 반복", value=1),
app_commands.Choice(name="큐 전체 반복", value=2)
])
async def loop_slash(interaction: discord.Interaction, mode: app_commands.Choice[int]):
guild_id = interaction.guild.id
loop_modes[guild_id] = mode.value # Choice 객체에서 실제 값을 가져옴
mode_text = mode.name # Choice 객체에서 선택된 이름을 가져옴
await interaction.response.send_message(f"🔄 반복 모드를 **{mode_text}**(으)로 설정했어요.", ephemeral=True)
반복 모드를 설정합니다. @app_commands.choices
를 사용해 사용자에게 선택지를 제공합니다.mode: app_commands.Choice[int]
타입 힌트를 사용하면, mode.value
로 정수 값을, mode.name
으로 선택한 항목의 이름을 가져올 수 있습니다.
/clear
@bot.tree.command(name="clear", description="음악 큐를 모두 지웁니다.")
async def clear_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in music_queues and music_queues[guild_id]:
cleared_count = len(music_queues[guild_id])
music_queues[guild_id].clear()
await interaction.response.send_message(f"🗑️ 큐에 있던 {cleared_count}곡을 모두 치웠어요!", ephemeral=True)
else:
await interaction.response.send_message("❌ 큐가 원래 비어있었어요.", ephemeral=True)
음악 큐를 모두 지웁니다.
/help
@bot.tree.command(name="help", description="봇 명령어 도움말을 보여줍니다.")
async def help_slash(interaction: discord.Interaction):
embed = discord.Embed(
title="📜 디스호스트 음악봇 도움말",
description="안녕하세요! 제가 할 수 있는 일들을 알려드릴게요.",
color=0x00aff4 # 하늘색
)
embed.add_field(
name="🎶 음악 재생",
value="`/play [노래 제목 또는 YouTube URL]` : 음악을 틀거나 큐에 넣어요.\n"
"`/queue` : 현재 대기열을 보여줘요.\n"
"`/skip` : 지금 나오는 노래를 건너뛰어요.\n"
"`/pause` : 잠깐 멈춤!\n"
"`/resume` : 다시 재생 시작!\n"
"`/stop` : 모든 걸 멈추고 큐도 비워요.\n"
"`/loop [모드]` : 반복 설정을 바꿔요 (없음, 현재 곡, 전체 큐).\n"
"`/clear` : 대기열을 싹 비워요.",
inline=False
)
embed.add_field(
name="🤖 봇 관련",
value="`/join` : 저를 음성 채널로 불러주세요.\n"
"`/leave` : 제가 음성 채널에서 나갈게요.\n"
"`/status` : 제 상태가 어떤지 알려줘요.\n"
"`/help` : 지금 보시는 이 도움말이에요!",
inline=False
)
embed.set_footer(text="문의사항은 관리자에게 | 디스호스트 음악봇 v2.0")
embed.set_thumbnail(url=bot.user.display_avatar.url) # 봇 프로필 사진을 썸네일로
await interaction.response.send_message(embed=embed, ephemeral=True)
봇 명령어 도움말을 Embed 메시지로 보여줍니다.
/status
@bot.tree.command(name="status", description="봇과 YouTube 연결 상태를 확인합니다.")
async def status_slash(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True) # 응답 지연, 사용자에게만 보이도록
embed = discord.Embed(
title="📊 디스호스트 음악봇 상태 체크!",
color=0x2ecc71 # 초록색 계열
)
# 기본 정보
embed.add_field(name="✅ 봇 상태", value="온라인", inline=True)
embed.add_field(name="💻 서버 수", value=f"{len(bot.guilds)}개", inline=True)
embed.add_field(name="🔊 음성 연결", value=f"{len(bot.voice_clients)}개", inline=True)
# 쿠키 파일 상태
cookie_file_exists = os.path.exists('cookies.txt')
cookie_status = "🍪 있음 (정상 작동 기대!)" if cookie_file_exists else "⚠️ 없음 (일부 기능 제한 가능)"
embed.add_field(name="🔑 쿠키 파일", value=cookie_status, inline=True)
# YouTube 연결 테스트 (간단한 검색 시도)
youtube_connection_status = "❓ 확인 중..."
try:
# asyncio.to_thread를 사용하여 동기 함수인 ydl.extract_info를 비동기적으로 실행
test_info = await asyncio.to_thread(
yt_dlp.YoutubeDL({**ytdlp_format_options, 'quiet': True, 'no_warnings': True, 'extract_flat': True, 'skip_download': True}).extract_info,
"ytsearch:test", # 간단한 검색어로 테스트
download=False
)
if test_info and ('entries' in test_info and test_info['entries']) or 'id' in test_info :
youtube_connection_status = "👍 정상 (YT-DLP 작동 중)"
else:
youtube_connection_status = "🤔 불안정 (YT-DLP 응답 확인 필요)"
except Exception as e:
logger.error(f"YouTube 연결 테스트 오류: {e}")
youtube_connection_status = f"❌ 오류 ({type(e).__name__})"
embed.add_field(name="📺 YouTube 연결", value=youtube_connection_status, inline=True)
# 현재 재생 정보 (명령어를 실행한 서버 기준)
guild_id = interaction.guild.id
current_song_title = "없음"
if guild_id in current_songs:
current_song_title = current_songs[guild_id]['title']
embed.add_field(name="🎶 현재 재생 (이 서버)", value=current_song_title[:100], inline=True) # 너무 길면 자르기
queue_length = 0
if guild_id in music_queues:
queue_length = len(music_queues[guild_id])
embed.add_field(name="📋 대기열 (이 서버)", value=f"{queue_length}곡", inline=True)
loop_mode_text = "반복 없음"
if guild_id in loop_modes:
loop_mode_text = ["반복 없음", "현재 곡 반복", "큐 전체 반복"][loop_modes[guild_id]]
embed.add_field(name="🔄 반복 모드 (이 서버)", value=loop_mode_text, inline=True)
embed.set_footer(text=f"지연시간: {round(bot.latency * 1000)}ms")
await interaction.followup.send(embed=embed)
봇의 현재 상태, 쿠키 파일 존재 여부, YouTube 연결 상태 등을 확인하여 Embed로 보여줍니다.asyncio.to_thread
를 사용하여 yt_dlp.YoutubeDL().extract_info
같은 동기 함수를 비동기 컨텍스트에서 안전하게 실행합니다. 이는 봇이 다른 작업을 처리하는 동안 해당 함수가 블로킹하는 것을 방지합니다.
10. 텍스트 명령어 (호환성용, 선택 사항)
# 텍스트 명령어들 (슬래시 명령어와 동일한 기능, 호환성을 위해 유지 또는 제거 가능)
# 예시: /play 명령어의 텍스트 버전
@bot.command(name='play', aliases=['p'])
async def play_text(ctx: commands.Context, *, query: str):
# 이 부분은 위의 play_slash 함수와 매우 유사하게 작성됩니다.
# 다만, interaction 대신 ctx (Context 객체)를 사용하고,
# interaction.response.defer() 대신 ctx.typing() 또는 그냥 진행,
# interaction.followup.send() 대신 ctx.send()를 사용합니다.
# 음성 채널 연결 로직 등도 거의 동일합니다.
# 여기서는 설명을 위해 간략히 구조만 남깁니다.
if not ctx.author.voice:
await ctx.send("❌ 음성 채널에 먼저 접속해주세요!")
return
channel = ctx.author.voice.channel
guild_id = ctx.guild.id
# ... (play_slash와 유사한 음성 채널 연결 로직) ...
if guild_id not in voice_clients or not voice_clients[guild_id].is_connected():
# ... connect or move ...
try:
if guild_id in voice_clients and voice_clients[guild_id].channel != channel:
await voice_clients[guild_id].move_to(channel)
else:
vc = await channel.connect()
voice_clients[guild_id] = vc
except Exception as e:
await ctx.send(f"❌ 음성 채널 연결 실패: {e}")
return
voice_client = voice_clients[guild_id]
song_info = await search_youtube(query)
if not song_info:
await ctx.send(f"❌ '{query}' 검색 결과 없음.")
return
if guild_id not in music_queues:
music_queues[guild_id] = []
if guild_id not in current_songs or not voice_client.is_playing():
success = await play_song(voice_client, song_info, guild_id)
if success:
await ctx.send(f"▶️ 지금 재생: **{song_info['title']}**")
# 텍스트 명령어에서는 MusicView를 직접 보내기 어려우므로,
# 상태 변경 메시지만 보내거나, 혹은 별도의 반응(reaction) 기반 UI를 고려할 수 있습니다.
else:
await ctx.send(f"❌ '{song_info['title']}' 재생 실패.")
else:
music_queues[guild_id].append(song_info)
await ctx.send(f"🎵 큐에 추가: **{song_info['title']}** ({len(music_queues[guild_id])}번째)")
# 다른 텍스트 명령어들 (skip, queue, pause, resume, stop, join, leave 등)도
# 각각의 슬래시 명령어와 유사한 로직으로 구현할 수 있습니다.
# @bot.command(name='skip', aliases=['s'])
# async def skip_text(ctx: commands.Context): ...
# @bot.command(name='queue', aliases=['q'])
# async def queue_text(ctx: commands.Context):
# embed = get_queue_embed(ctx.guild.id)
# await ctx.send(embed=embed) # 텍스트 명령어에서도 Embed 전송은 가능
# (이하 생략)
# 제공된 bot.py에는 모든 텍스트 명령어가 구현되어 있으니 참고하세요.
슬래시 명령어가 대세이지만, 예전 방식의 텍스트 명령어(!play
등)를 지원하고 싶다면 이렇게 추가할 수 있습니다. 로직은 슬래시 명령어와 거의 동일하며, interaction
대신 ctx
(Context) 객체를 사용하고, 응답 방식이 조금 다릅니다. 제공된 bot.py
에는 이 텍스트 명령어들이 모두 구현되어 있습니다.
11. 봇 실행
if __name__ == "__main__":
if not TOKEN:
logger.error("환경변수에서 Discord 토큰을 찾을 수 없어요! .env 파일을 확인해주세요.")
else:
try:
bot.run(TOKEN)
except discord.LoginFailure:
logger.error("Discord 로그인 실패! 토큰이 정확한지 확인해주세요.")
except Exception as e:
logger.error(f"봇 실행 중 알 수 없는 오류 발생: {e}")
드디어 마지막입니다! 이 코드는 스크립트가 직접 실행될 때(python bot.py
처럼)만 작동합니다.
if not TOKEN
: Discord 봇 토큰이 제대로 로드되었는지 확인합니다. 없으면 오류 메시지를 출력합니다.bot.run(TOKEN)
: 이 한 줄이 실제로 봇을 Discord 서버에 연결하고 실행시킵니다.try...except
블록: 로그인 실패 등 봇 실행 중에 발생할 수 있는 주요 오류들을 잡아 적절한 로그를 남기도록 합니다.
이제 bot.py
파일의 전체 구조와 각 부분의 역할을 이해하셨을 겁니다! 이 코드를 기반으로 여러분만의 기능을 추가하거나 수정해보세요. 예를 들어, SoundCloud 지원, 가사 검색 기능, 관리자 전용 명령어 등을 만들어볼 수 있겠죠?
다음 섹션에서는 이 봇을 디스호스트에 올려서 24시간 돌아가도록 만드는 방법을 알아볼게요.
13. 풀 코드
import discord
from discord import app_commands
from discord.ext import commands
import os
import asyncio
import yt_dlp
import logging
from dotenv import load_dotenv
from typing import Dict, List, Optional, Any, Union
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('dishost_musicbot')
# .env 파일 로드 (Discord 토큰 등 환경변수)
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')
# 봇 설정
intents = discord.Intents.default()
intents.message_content = True # 메시지 내용 읽기 권한
intents.voice_states = True # 음성 상태 추적 권한
# 기본 help 명령어 비활성화
bot = commands.Bot(command_prefix='/', intents=intents, help_command=None)
# YT-DLP 설정 (쿠키 기반)
ytdlp_format_options = {
'format': 'bestaudio[ext=webm]/bestaudio/best[ext=webm]/best',
'no_playlist': True,
'noplaylist': True,
'quiet': True,
'no_warnings': True,
'default_search': 'auto',
'source_address': '0.0.0.0',
'geo_bypass': True,
'extractor_retries': 3,
'nocheckcertificate': True,
'age_limit': 99,
'extract_flat': False,
'ignoreerrors': True,
'cookiefile': 'cookies.txt', # 쿠키 파일 사용
'http_headers': {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-us,en;q=0.5',
'Accept-Encoding': 'gzip,deflate',
'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
'Keep-Alive': '300',
'Connection': 'keep-alive',
},
}
ffmpeg_options = {
'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
'options': '-vn -filter:a "volume=0.5"'
}
# 전역 변수들
music_queues: Dict[int, List[Dict[str, Any]]] = {}
voice_clients: Dict[int, discord.VoiceClient] = {}
current_songs: Dict[int, Dict[str, Any]] = {}
loop_modes: Dict[int, int] = {} # 0: 반복없음, 1: 현재곡 반복, 2: 큐 반복
class MusicView(discord.ui.View):
def __init__(self, guild_id: int):
super().__init__(timeout=None)
self.guild_id = guild_id
@discord.ui.button(label='⏸️ 일시정지', style=discord.ButtonStyle.secondary)
async def pause_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
voice_clients[self.guild_id].pause()
await interaction.followup.send("⏸️ 음악이 일시정지되었습니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 재생 중인 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='▶️ 재생', style=discord.ButtonStyle.secondary)
async def resume_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_paused():
voice_clients[self.guild_id].resume()
await interaction.followup.send("▶️ 음악이 재개되었습니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 일시정지된 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='⏭️ 스킵', style=discord.ButtonStyle.secondary)
async def skip_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in voice_clients and voice_clients[self.guild_id].is_playing():
voice_clients[self.guild_id].stop()
await interaction.followup.send("⏭️ 다음 곡으로 스킵합니다.", ephemeral=True)
else:
await interaction.followup.send("❌ 재생 중인 음악이 없습니다.", ephemeral=True)
@discord.ui.button(label='⏹️ 정지', style=discord.ButtonStyle.danger)
async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
if self.guild_id in music_queues:
music_queues[self.guild_id].clear()
if self.guild_id in voice_clients:
voice_clients[self.guild_id].stop()
if self.guild_id in current_songs:
del current_songs[self.guild_id]
await interaction.followup.send("⏹️ 음악이 정지되고 큐가 초기화되었습니다.", ephemeral=True)
def get_queue_embed(guild_id: int) -> discord.Embed:
"""현재 큐 상태를 임베드로 생성"""
embed = discord.Embed(title="🎵 음악 큐", color=0x3498db)
if guild_id in current_songs:
current = current_songs[guild_id]
embed.add_field(
name="🎶 현재 재생 중",
value=f"**{current['title']}**\n⏱️ {current.get('duration', 'Unknown')}\n🎤 {current.get('uploader', 'Unknown')}",
inline=False
)
if guild_id in music_queues and music_queues[guild_id]:
queue_text = ""
for i, song in enumerate(music_queues[guild_id][:5]): # 최대 5개만 표시
queue_text += f"{i+1}. **{song['title']}** ({song.get('duration', 'Unknown')})\n"
if len(music_queues[guild_id]) > 5:
queue_text += f"... 그리고 {len(music_queues[guild_id]) - 5}곡 더"
embed.add_field(name="📋 대기열", value=queue_text, inline=False)
else:
embed.add_field(name="📋 대기열", value="비어있음", inline=False)
# 반복 모드 표시
loop_mode = loop_modes.get(guild_id, 0)
loop_text = ["🔁 반복 없음", "🔂 현재 곡 반복", "🔁 큐 반복"][loop_mode]
embed.add_field(name="🔄 반복 모드", value=loop_text, inline=True)
embed.set_footer(text="YT-DLP (쿠키) • 디스호스트 음악봇 v2.0")
return embed
async def play_song(voice_client: discord.VoiceClient, song_info: Dict[str, Any], guild_id: int):
"""YT-DLP를 사용하여 음악 재생 (쿠키 기반)"""
try:
# YT-DLP로 스트림 URL 추출
with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
info = ydl.extract_info(song_info['url'], download=False)
if not info:
logger.error("YT-DLP에서 정보를 가져올 수 없습니다")
return False
url = info.get('url')
if not url:
logger.error("스트림 URL을 찾을 수 없습니다")
return False
# 음악 재생
source = discord.FFmpegPCMAudio(url, **ffmpeg_options)
voice_client.play(source, after=lambda e: asyncio.run_coroutine_threadsafe(
play_next(guild_id), bot.loop).result() if not e else logger.error(f'Player error: {e}'))
current_songs[guild_id] = song_info
logger.info(f"재생 시작: {song_info['title']}")
return True
except Exception as e:
logger.error(f"음악 재생 중 오류: {e}")
return False
async def play_next(guild_id: int):
"""다음 곡 재생"""
try:
if guild_id not in voice_clients:
return
voice_client = voice_clients[guild_id]
# 반복 모드 확인
loop_mode = loop_modes.get(guild_id, 0)
if loop_mode == 1: # 현재 곡 반복
if guild_id in current_songs:
await play_song(voice_client, current_songs[guild_id], guild_id)
return
if guild_id in music_queues and music_queues[guild_id]:
next_song = music_queues[guild_id].pop(0)
# 큐 반복 모드면 곡을 다시 큐 끝에 추가
if loop_mode == 2:
music_queues[guild_id].append(next_song)
await play_song(voice_client, next_song, guild_id)
else:
# 큐가 비어있으면 현재 곡 정보 제거
if guild_id in current_songs:
del current_songs[guild_id]
except Exception as e:
logger.error(f"다음 곡 재생 중 오류: {e}")
async def search_youtube(query: str) -> Optional[Dict[str, Any]]:
"""YouTube에서 검색 (쿠키 기반)"""
try:
with yt_dlp.YoutubeDL(ytdlp_format_options) as ydl:
# URL이면 직접 처리, 검색어면 ytsearch: 접두사 사용
if query.startswith(('http://', 'https://')):
search_query = query
else:
search_query = f"ytsearch:{query}"
info = ydl.extract_info(search_query, download=False)
if not info:
return None
# 검색 결과에서 첫 번째 항목 가져오기
if 'entries' in info and info['entries']:
video_info = info['entries'][0]
else:
video_info = info
return {
'title': video_info.get('title', 'Unknown Title'),
'url': video_info.get('webpage_url', video_info.get('url', '')),
'duration': str(video_info.get('duration', 'Unknown')),
'uploader': video_info.get('uploader', 'Unknown'),
'thumbnail': video_info.get('thumbnail', ''),
'view_count': video_info.get('view_count', 0)
}
except Exception as e:
logger.error(f"YouTube 검색 중 오류: {e}")
return None
@bot.event
async def on_ready():
logger.info(f'{bot.user}가 로그인했습니다!')
# 로컬 명령어 확인
local_commands = bot.tree.get_commands()
logger.info(f"📋 로컬에 정의된 명령어 수: {len(local_commands)}")
for cmd in local_commands:
logger.info(f" ✓ {cmd.name}: {cmd.description}")
# 봇이 속한 길드 수 확인
guild_count = len(bot.guilds)
logger.info(f"🏠 봇이 속한 서버 수: {guild_count}")
logger.info("🏠 길드별 명령어로 동기화합니다")
await sync_guild_commands()
# 봇 상태 설정
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.listening,
name="디스호스트 음악봇 | /play로 음악 재생"
)
)
async def sync_global_commands():
"""글로벌 명령어 동기화"""
max_retries = 3
for attempt in range(max_retries):
try:
logger.info(f"🌍 글로벌 명령어 동기화 시도 {attempt + 1}/{max_retries}...")
# 기존 글로벌 명령어 정리
logger.info("🔄 기존 글로벌 명령어 정리 중...")
bot.tree.clear_commands(guild=None)
# 글로벌 동기화 (최대 60초 대기)
synced = await asyncio.wait_for(
bot.tree.sync(),
timeout=60.0
)
logger.info(f"✅ 글로벌 동기화 완료: {len(synced)}개 명령어")
for cmd in synced:
logger.info(f" - /{cmd.name}: {cmd.description}")
logger.info("⚠️ 글로벌 명령어는 적용까지 최대 1시간이 걸릴 수 있습니다.")
return True
except asyncio.TimeoutError:
logger.error(f"⏰ 글로벌 동기화 타임아웃 (60초)")
except Exception as e:
logger.error(f"❌ 글로벌 동기화 실패 (시도 {attempt + 1}): {e}")
if attempt < max_retries - 1:
wait_time = 2 ** attempt # 지수 백오프: 1초, 2초, 4초
logger.info(f"🕒 {wait_time}초 후 재시도합니다...")
await asyncio.sleep(wait_time)
logger.error("❌ 글로벌 명령어 동기화가 모두 실패했습니다.")
return False
async def sync_guild_commands():
"""길드별 명령어 동기화"""
max_retries = 3
successful_guilds = 0
failed_guilds = 0
for attempt in range(max_retries):
try:
logger.info(f"🏠 길드별 명령어 동기화 시도 {attempt + 1}/{max_retries}...")
# 각 길드별로 동기화
for guild in bot.guilds:
try:
logger.info(f"🔄 {guild.name}({guild.id}) 동기화 중...")
# 기존 길드 명령어 정리
bot.tree.clear_commands(guild=guild)
# 길드 동기화 (길드당 45초 타임아웃)
synced = await asyncio.wait_for(
bot.tree.sync(guild=guild),
timeout=45.0
)
successful_guilds += 1
logger.info(f"✅ {guild.name}: {len(synced)}개 명령어 동기화 완료")
# 길드 간 간격을 두어 레이트 리미트 방지
await asyncio.sleep(1)
except asyncio.TimeoutError:
failed_guilds += 1
logger.error(f"⏰ {guild.name} 동기화 타임아웃 (45초)")
except Exception as guild_error:
failed_guilds += 1
logger.error(f"❌ {guild.name} 동기화 실패: {guild_error}")
if successful_guilds > 0:
logger.info(f"🎉 성공: {successful_guilds}개 서버, 실패: {failed_guilds}개 서버")
return True
else:
logger.error("❌ 모든 길드 동기화가 실패했습니다.")
except Exception as e:
logger.error(f"❌ 길드 동기화 과정에서 오류 발생: {e}")
if attempt < max_retries - 1:
wait_time = 3 * (attempt + 1) # 3초, 6초, 9초
logger.info(f"🕒 {wait_time}초 후 재시도합니다...")
await asyncio.sleep(wait_time)
# 길드 동기화가 모두 실패한 경우 글로벌로 폴백
logger.warning("⚠️ 길드 동기화 실패, 글로벌 명령어로 폴백합니다...")
return await sync_global_commands()
@bot.tree.command(name="play", description="디스호스트 음악봇 - 음악을 재생합니다")
@app_commands.describe(query="재생할 음악의 제목이나 YouTube URL")
async def play_slash(interaction: discord.Interaction, query: str):
await interaction.response.defer()
# 사용자가 음성 채널에 있는지 확인
if not interaction.user.voice:
await interaction.followup.send("❌ 음성 채널에 먼저 접속해주세요!")
return
channel = interaction.user.voice.channel
guild_id = interaction.guild.id
# 봇이 음성 채널에 연결되어 있지 않으면 연결
if guild_id not in voice_clients:
try:
voice_client = await channel.connect()
voice_clients[guild_id] = voice_client
except Exception as e:
await interaction.followup.send(f"❌ 음성 채널 연결 실패: {e}")
return
# YouTube에서 검색
song_info = await search_youtube(query)
if not song_info:
await interaction.followup.send("❌ 검색 결과를 찾을 수 없습니다.")
return
# 큐에 추가
if guild_id not in music_queues:
music_queues[guild_id] = []
# 현재 재생 중이 아니면 바로 재생, 아니면 큐에 추가
if guild_id not in current_songs or not voice_clients[guild_id].is_playing():
success = await play_song(voice_clients[guild_id], song_info, guild_id)
if success:
embed = discord.Embed(
title="🎵 재생 시작",
description=f"**{song_info['title']}**",
color=0x00ff00
)
embed.add_field(name="⏱️ 재생 시간", value=song_info['duration'], inline=True)
embed.add_field(name="🎤 업로더", value=song_info['uploader'], inline=True)
if song_info['thumbnail']:
embed.set_thumbnail(url=song_info['thumbnail'])
view = MusicView(guild_id)
await interaction.followup.send(embed=embed, view=view)
else:
await interaction.followup.send("❌ 음악 재생에 실패했습니다.")
else:
music_queues[guild_id].append(song_info)
embed = discord.Embed(
title="➕ 큐에 추가됨",
description=f"**{song_info['title']}**",
color=0x3498db
)
embed.add_field(name="📋 대기열 위치", value=f"{len(music_queues[guild_id])}번째", inline=True)
embed.add_field(name="⏱️ 재생 시간", value=song_info['duration'], inline=True)
if song_info['thumbnail']:
embed.set_thumbnail(url=song_info['thumbnail'])
await interaction.followup.send(embed=embed)
@bot.tree.command(name="queue", description="디스호스트 음악봇 - 현재 음악 큐를 확인합니다")
async def queue_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
embed = get_queue_embed(guild_id)
view = MusicView(guild_id)
await interaction.response.send_message(embed=embed, view=view)
@bot.tree.command(name="skip", description="디스호스트 음악봇 - 현재 곡을 스킵합니다")
async def skip_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].stop()
await interaction.response.send_message("⏭️ 다음 곡으로 스킵합니다.")
else:
await interaction.response.send_message("❌ 재생 중인 음악이 없습니다.")
@bot.tree.command(name="pause", description="디스호스트 음악봇 - 음악을 일시정지합니다")
async def pause_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].pause()
await interaction.response.send_message("⏸️ 음악이 일시정지되었습니다.")
else:
await interaction.response.send_message("❌ 재생 중인 음악이 없습니다.")
@bot.tree.command(name="resume", description="디스호스트 음악봇 - 일시정지된 음악을 재개합니다")
async def resume_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_paused():
voice_clients[guild_id].resume()
await interaction.response.send_message("▶️ 음악이 재개되었습니다.")
else:
await interaction.response.send_message("❌ 일시정지된 음악이 없습니다.")
@bot.tree.command(name="stop", description="디스호스트 음악봇 - 음악을 정지하고 큐를 초기화합니다")
async def stop_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in music_queues:
music_queues[guild_id].clear()
if guild_id in voice_clients:
voice_clients[guild_id].stop()
if guild_id in current_songs:
del current_songs[guild_id]
await interaction.response.send_message("⏹️ 음악이 정지되고 큐가 초기화되었습니다.")
@bot.tree.command(name="join", description="디스호스트 음악봇 - 음성 채널에 참가합니다")
async def join_slash(interaction: discord.Interaction):
# 사용자가 음성 채널에 있는지 확인
if not interaction.user.voice:
await interaction.response.send_message("❌ 음성 채널에 먼저 접속해주세요!")
return
channel = interaction.user.voice.channel
guild_id = interaction.guild.id
# 이미 연결되어 있는지 확인
if guild_id in voice_clients:
if voice_clients[guild_id].channel == channel:
await interaction.response.send_message("✅ 이미 해당 음성 채널에 연결되어 있습니다.")
return
else:
# 다른 채널에 연결되어 있으면 새 채널로 이동
await voice_clients[guild_id].move_to(channel)
await interaction.response.send_message(f"🔄 **{channel.name}** 채널로 이동했습니다.")
return
# 음성 채널에 연결
try:
voice_client = await channel.connect()
voice_clients[guild_id] = voice_client
await interaction.response.send_message(f"✅ **{channel.name}** 채널에 참가했습니다!")
except Exception as e:
await interaction.response.send_message(f"❌ 음성 채널 연결 실패: {e}")
@bot.tree.command(name="leave", description="디스호스트 음악봇 - 음성 채널에서 나갑니다")
async def leave_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in voice_clients:
await voice_clients[guild_id].disconnect()
del voice_clients[guild_id]
if guild_id in music_queues:
del music_queues[guild_id]
if guild_id in current_songs:
del current_songs[guild_id]
if guild_id in loop_modes:
del loop_modes[guild_id]
await interaction.response.send_message("👋 음성 채널에서 나갔습니다.")
else:
await interaction.response.send_message("❌ 음성 채널에 연결되어 있지 않습니다.")
@bot.tree.command(name="loop", description="디스호스트 음악봇 - 반복 모드를 설정합니다")
@app_commands.describe(mode="반복 모드 (0: 반복없음, 1: 현재곡 반복, 2: 큐 반복)")
@app_commands.choices(mode=[
app_commands.Choice(name="반복 없음", value=0),
app_commands.Choice(name="현재 곡 반복", value=1),
app_commands.Choice(name="큐 반복", value=2)
])
async def loop_slash(interaction: discord.Interaction, mode: int):
guild_id = interaction.guild.id
loop_modes[guild_id] = mode
mode_text = ["🔁 반복 없음", "🔂 현재 곡 반복", "🔁 큐 반복"][mode]
await interaction.response.send_message(f"반복 모드가 **{mode_text}**로 설정되었습니다.")
@bot.tree.command(name="clear", description="디스호스트 음악봇 - 음악 큐를 모두 지웁니다")
async def clear_slash(interaction: discord.Interaction):
guild_id = interaction.guild.id
if guild_id in music_queues:
cleared_count = len(music_queues[guild_id])
music_queues[guild_id].clear()
await interaction.response.send_message(f"🗑️ 큐에서 {cleared_count}곡이 제거되었습니다.")
else:
await interaction.response.send_message("❌ 큐가 이미 비어있습니다.")
@bot.tree.command(name="help", description="디스호스트 음악봇 - 도움말을 표시합니다")
async def help_slash(interaction: discord.Interaction):
embed = discord.Embed(title="🎵 디스호스트 음악봇 도움말", color=0x3498db)
commands_text = """
`/play <검색어 또는 URL>` - 음악 재생 또는 큐에 추가
`/join` - 음성 채널에 참가
`/queue` - 현재 큐 확인
`/skip` - 현재 곡 스킵
`/pause` - 일시정지
`/resume` - 재생 재개
`/stop` - 정지 및 큐 초기화
`/loop <모드>` - 반복 모드 설정
`/clear` - 큐 비우기
`/leave` - 음성 채널에서 나가기
`/status` - 봇 상태 및 YouTube 연결 확인
`/help` - 이 도움말 표시
"""
embed.add_field(name="📋 명령어 목록", value=commands_text, inline=False)
embed.add_field(name="🔄 반복 모드", value="0: 반복없음\n1: 현재곡 반복\n2: 큐 반복", inline=True)
embed.add_field(name="🎵 지원 플랫폼", value="YouTube (쿠키 기반)", inline=True)
embed.set_footer(text="YT-DLP (쿠키) • 디스호스트 음악봇 v2.0")
await interaction.response.send_message(embed=embed)
@bot.tree.command(name="status", description="디스호스트 음악봇 - 봇과 YouTube 연결 상태를 확인합니다")
async def status_slash(interaction: discord.Interaction):
await interaction.response.defer()
embed = discord.Embed(
title="🤖 디스호스트 음악봇 상태",
color=0x3498db
)
# 기본 정보
embed.add_field(name="📊 봇 상태", value="✅ 온라인", inline=True)
embed.add_field(name="🌐 지연시간", value=f"{round(bot.latency * 1000)}ms", inline=True)
embed.add_field(name="🎵 음성 연결", value=f"{len(bot.voice_clients)}개 서버", inline=True)
# 쿠키 파일 상태 확인
import os
cookie_status = "✅ 사용 가능" if os.path.exists('cookies.txt') else "❌ 없음"
embed.add_field(name="🍪 쿠키 파일", value=cookie_status, inline=True)
# YouTube 연결 테스트
try:
test_options = ytdlp_format_options.copy()
test_options['quiet'] = True
test_options['no_warnings'] = True
with yt_dlp.YoutubeDL(test_options) as ydl:
# 간단한 YouTube 비디오로 테스트
test_info = await asyncio.to_thread(ydl.extract_info, "ytsearch:test", download=False)
if test_info and 'entries' in test_info and test_info['entries']:
youtube_status = "✅ 정상"
extraction_method = "YT-DLP (쿠키)"
else:
youtube_status = "⚠️ 제한적"
extraction_method = "제한된 접근"
except Exception as e:
youtube_status = "❌ 오류"
extraction_method = f"오류: {str(e)[:50]}"
embed.add_field(name="📺 YouTube 상태", value=youtube_status, inline=True)
embed.add_field(name="🔧 추출 방법", value=extraction_method, inline=True)
# 서버별 재생 상태
active_players = len([guild_id for guild_id in current_songs.keys()])
embed.add_field(name="🎶 활성 플레이어", value=f"{active_players}개", inline=True)
# 큐 정보
total_queued = sum(len(queue) for queue in music_queues.values())
embed.add_field(name="📋 대기열 총 곡수", value=f"{total_queued}곡", inline=True)
embed.set_footer(text="디스호스트 음악봇 v2.0 - YT-DLP (쿠키) 기반")
await interaction.followup.send(embed=embed)
# 텍스트 명령어들 (호환성을 위해 유지)
@bot.command(name='play', aliases=['p'])
async def play_text(ctx, *, query):
"""텍스트 명령어로 음악 재생"""
if not ctx.author.voice:
await ctx.send("❌ 음성 채널에 먼저 접속해주세요!")
return
channel = ctx.author.voice.channel
guild_id = ctx.guild.id
# 봇이 음성 채널에 연결되어 있지 않으면 연결
if guild_id not in voice_clients:
try:
voice_client = await channel.connect()
voice_clients[guild_id] = voice_client
except Exception as e:
await ctx.send(f"❌ 음성 채널 연결 실패: {e}")
return
# YouTube에서 검색
song_info = await search_youtube(query)
if not song_info:
await ctx.send("❌ 검색 결과를 찾을 수 없습니다.")
return
# 큐에 추가
if guild_id not in music_queues:
music_queues[guild_id] = []
# 현재 재생 중이 아니면 바로 재생, 아니면 큐에 추가
if guild_id not in current_songs or not voice_clients[guild_id].is_playing():
success = await play_song(voice_clients[guild_id], song_info, guild_id)
if success:
await ctx.send(f"🎵 재생 시작: **{song_info['title']}**")
else:
await ctx.send("❌ 음악 재생에 실패했습니다.")
else:
music_queues[guild_id].append(song_info)
await ctx.send(f"➕ 큐에 추가됨: **{song_info['title']}** (대기열 {len(music_queues[guild_id])}번째)")
@bot.command(name='skip', aliases=['s'])
async def skip_text(ctx):
guild_id = ctx.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].stop()
await ctx.send("⏭️ 다음 곡으로 스킵합니다.")
else:
await ctx.send("❌ 재생 중인 음악이 없습니다.")
@bot.command(name='queue', aliases=['q'])
async def queue_text(ctx):
guild_id = ctx.guild.id
embed = get_queue_embed(guild_id)
await ctx.send(embed=embed)
@bot.command(name='pause')
async def pause_text(ctx):
guild_id = ctx.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_playing():
voice_clients[guild_id].pause()
await ctx.send("⏸️ 음악이 일시정지되었습니다.")
else:
await ctx.send("❌ 재생 중인 음악이 없습니다.")
@bot.command(name='resume')
async def resume_text(ctx):
guild_id = ctx.guild.id
if guild_id in voice_clients and voice_clients[guild_id].is_paused():
voice_clients[guild_id].resume()
await ctx.send("▶️ 음악이 재개되었습니다.")
else:
await ctx.send("❌ 일시정지된 음악이 없습니다.")
@bot.command(name='stop')
async def stop_text(ctx):
guild_id = ctx.guild.id
if guild_id in music_queues:
music_queues[guild_id].clear()
if guild_id in voice_clients:
voice_clients[guild_id].stop()
if guild_id in current_songs:
del current_songs[guild_id]
await ctx.send("⏹️ 음악이 정지되고 큐가 초기화되었습니다.")
@bot.command(name='join', aliases=['connect'])
async def join_text(ctx):
"""텍스트 명령어로 음성 채널 참가"""
if not ctx.author.voice:
await ctx.send("❌ 음성 채널에 먼저 접속해주세요!")
return
channel = ctx.author.voice.channel
guild_id = ctx.guild.id
# 이미 연결되어 있는지 확인
if guild_id in voice_clients:
if voice_clients[guild_id].channel == channel:
await ctx.send("✅ 이미 해당 음성 채널에 연결되어 있습니다.")
return
else:
# 다른 채널에 연결되어 있으면 새 채널로 이동
await voice_clients[guild_id].move_to(channel)
await ctx.send(f"🔄 **{channel.name}** 채널로 이동했습니다.")
return
# 음성 채널에 연결
try:
voice_client = await channel.connect()
voice_clients[guild_id] = voice_client
await ctx.send(f"✅ **{channel.name}** 채널에 참가했습니다!")
except Exception as e:
await ctx.send(f"❌ 음성 채널 연결 실패: {e}")
@bot.command(name='leave', aliases=['disconnect'])
async def leave_text(ctx):
guild_id = ctx.guild.id
if guild_id in voice_clients:
await voice_clients[guild_id].disconnect()
del voice_clients[guild_id]
if guild_id in music_queues:
del music_queues[guild_id]
if guild_id in current_songs:
del current_songs[guild_id]
if guild_id in loop_modes:
del loop_modes[guild_id]
await ctx.send("👋 음성 채널에서 나갔습니다.")
else:
await ctx.send("❌ 음성 채널에 연결되어 있지 않습니다.")
if __name__ == "__main__":
if not TOKEN:
logger.error("Discord 토큰이 설정되지 않았습니다. .env 파일을 확인해주세요.")
else:
bot.run(TOKEN)
디스호스트에 봇 호스팅하기
이제 완성된 봇을 디스호스트에 호스팅해보겠습니다. 디스호스트는 Discord 봇을 쉽게 호스팅할 수 있는 플랫폼입니다.
1. 디스호스트 계정 생성 및 로그인
먼저 디스호스트에 로그인해야합니다. 디스호스트 계정이 없다면 디스호스트 공식 웹사이트에서 계정을 생성하세요.
2. 크레딧 충전
디스호스트에서 봇을 24시간 운영하려면 크레딧을 충전해야 합니다.
- 대시보드 접속: 디스호스트 웹사이트에 로그인하여 대시보드로 이동합니다.
- 크레딧 추가 메뉴: 왼쪽 사이드바에서 '크레딧 추가' 메뉴를 클릭합니다.
- 충전 금액 선택:
- 사전 설정된 금액(10,000원, 50,000원, 100,000원 등) 중 선택하거나
- 직접 원하는 금액을 입력합니다 (최소 1,000원)
- 충전 방법 선택: 현재 지원되는 충전 방법은 다음과 같습니다:
- 계좌이체: 실시간 계좌이체 (수수료 없음)
- 입금자명을 정확히 입력
- 표시된 계좌로 정확한 금액을 5분 이내에 입금
- '입금 완료 확인하기' 버튼 클릭하여 확인
- 문화상품권: 문화상품권 핀번호 입력 (10% 수수료 적용)
- 상품권 핀번호를 정확히 입력
- 실제 충전 크레딧 = 상품권 금액의 90%
- 계좌이체: 실시간 계좌이체 (수수료 없음)
3. 봇 관리 패널 연동
디스호스트에서 봇을 관리하기 위해서는 봇 관리 패널 계정을 연동해야 합니다.
패널 연동 절차
- 대시보드 접속: 디스호스트 웹사이트 로그인 후 대시보드 진입
- 사용자 정보 탭: 좌측 사이드바에서 '사용자 정보' 메뉴 선택
- 계정 연동 설정: 'Pterodactyl 계정 연동' 섹션에서 다음 정보 입력
- 이메일: Pterodactyl 계정 전용 이메일 주소
- 사용자명: 패널 로그인 ID
- 비밀번호: 강력한 보안 비밀번호 (특수문자, 대소문자, 숫자 조합)
- 계정 생성: '계정 생성' 버튼 클릭하여 연동 완료
중요 보안 고려사항
경고: Pterodactyl 계정은 연동 해제가 불가능하며, 로그인 정보는 대시보드에서 재확인할 수 없습니다. 입력한 정보를 안전한 장소에 기록하여 보관하시기 바랍니다.
4. 봇 서버 생성
크레딧 충전과 패널 연동이 완료된 후 봇 서버를 생성합니다.
서버 생성 절차
- 봇 관리 페이지: 대시보드에서 '내 봇' 탭으로 이동
- 새 서버 추가: '새 서버 추가하기' 버튼 클릭
- 개발 환경 선택: Python 언어 환경 선택
- 리소스 플랜 선택: 봇 규모에 적합한 플랜 선택
- Starter: 소규모 봇을 위한 기본 플랜
- Standard: 중간 규모 봇을 위한 표준 플랜
- Enterprise: 대규모 봇을 위한 고급 플랜
- 서버 구매: '봇 구매하기' 버튼을 통해 결제 진행
서버 생성 대기
서버 생성 프로세스는 일반적으로 1-3분 소요되며, 완료 시 성공 메시지가 표시됩니다.
5. Pterodactyl 패널을 통한 봇 배포
패널 접속
- 패널 접속: https://panel.dishost.kr로 이동
- 로그인: 앞서 설정한 사용자명과 비밀번호로 인증
- 서버 선택: 생성된 봇 서버를 목록에서 선택
봇 코드 업로드
업로드 절차
- 파일 관리자: 서버 관리 페이지에서 'Files' 탭 선택
- 파일 생성: 새 파일 버튼 클릭하여 파일 생성
- 봇 붙여넣기: 만든 봇을 붙여넣기
- 저장: 파일 생성 버튼을 눌러 저장, 이름은 app.py로 생성
봇 시작 설정
- Startup 탭: 서버 관리 페이지에서 'Startup' 탭 선택
- 시작 파일 설정: 'STARTUP FILE' 필드에 메인 Python 파일명 입력 (예: app.py)
- 의존성 설정: 'Additional Python packages' 필드에 필요한 패키지 입력
- discord.py
- yt-dlp
- PyNaCl
- 설정 저장: 'Save' 버튼으로 변경사항 저장
봇 실행
- 콘솔 접속: 'Console' 탭으로 이동
- 서버 시작: 'Start' 버튼 클릭하여 봇 실행
- 로그 모니터링: 콘솔 출력을 통해 정상 시작 확인
- 상태 검증: Discord에서 봇의 온라인 상태 확인
운영 고려사항
- 자동 연장 없음: 봇 서버는 자동 연장되지 않으므로 만료 전 수동 연장 필요
- 만료 후 유예기간: 서버 데이터는 만료 후 7일간 보존
- 연장 권장시점: 만료일 최소 3일 전 연장 처리
정상적으로 실행되면 봇이 온라인 상태로 표시되며, Discord 서버에서 슬래시 명령어를 사용할 수 있습니다.