Discord.py로 디스코드 음악 봇 만들기: 디스호스트로 24시간 호스팅까지!

2025. 6. 4. 15:42·봇 개발 팁/Discord.py

안녕하세요! 혹시 나만의 디스코드 음악 봇을 갖고 싶다는 생각, 한 번쯤 해보셨나요? 이 가이드를 통해 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 봇을 생성하는 방법을 자세히 알아보세요.

Discord 봇 생성하기

쿠키 추출 방법

YouTube와 같은 사이트에서 로그인 필요한 콘텐츠나 연령 제한이 있는 콘텐츠를 재생하기 위해서는 쿠키가 필요합니다. 파이어폭스 브라우저와 cookies.txt 확장 프로그램을 사용하여 쿠키를 추출할 수 있습니다. (크롬 브라우저에서는 쿠키 추출이 잘 되지 않는 것을 확인했습니다.)

  1. 파이어폭스 브라우저를 열고, cookies.txt 확장 프로그램을 설치합니다.
  2. YouTube에 로그인합니다. (로그인 상태여야 합니다.)
  3. https://www.youtube.com/robot.txt 페이지로 이동합니다.
  4. 확장 프로그램 아이콘을 클릭하고 "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

실제로 노래를 재생하는 함수입니다.

  1. search_youtube에서 얻은 song_info (특히 song_info['url']은 웹페이지 URL)를 사용해 yt-dlp로 다시 한번 extract_info를 호출합니다. 이번에는 실제 오디오 스트림 URL을 얻기 위함입니다. YouTube의 스트림 URL은 시간이 지나면 만료될 수 있어서, 재생 직전에 다시 얻는 것이 안정적입니다.
  2. info 딕셔너리에서 실제 스트리밍 가능한 URL (stream_url)을 찾습니다. 때로는 info['url']에 바로 있기도 하고, info['formats'] 리스트 안에서 적절한 오디오 포맷을 찾아야 할 수도 있습니다. 여기서는 오디오 코덱이 있고 비디오 코덱이 없는 포맷을 우선적으로 찾습니다.
  3. discord.FFmpegPCMAudio(stream_url, **ffmpeg_options): 찾은 스트림 URL과 FFmpeg 옵션을 사용해 오디오 소스를 만듭니다.
  4. 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) 함수를 비동기적으로 안전하게 실행하여 다음 곡을 재생합니다.
      • 오류가 있었다면 로그를 남깁니다.
  5. 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]

이전 곡 재생이 끝나면 호출되어 다음 곡을 재생하거나 반복 설정을 처리합니다.

  1. 반복 모드 확인: loop_modes를 보고 현재 서버의 반복 설정을 가져옵니다.
  2. 현재 곡 반복 (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일 때만 수행됩니다.
  3. 큐에서 다음 곡 재생:
    • music_queues[guild_id].pop(0): 큐의 맨 앞에 있는 곡을 꺼냅니다.
    • 큐 반복 (loop_mode == 2): 꺼낸 곡을 다시 큐의 맨 뒤에 추가합니다.
    • 꺼낸 곡 정보로 play_song을 호출하여 재생합니다.
  4. 큐가 비었을 때: 더 이상 재생할 곡이 없으면 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)

음악을 재생하거나 큐에 추가합니다.

  1. await interaction.response.defer(): 명령어 처리에 시간이 걸릴 수 있으므로, Discord에게 "처리 중"임을 알립니다. ephemeral=True를 사용하면 이 메시지는 명령어 사용자에게만 보입니다. (여기서는 defer만 하고 실제 응답은 followup.send로 합니다.)
  2. 사용자가 음성 채널에 있는지 확인합니다. 없으면 오류 메시지를 보냅니다.
  3. 봇을 사용자의 음성 채널에 연결합니다.
    • 이미 다른 채널에 연결되어 있다면 그 채널로 이동합니다.
    • 아직 연결되어 있지 않다면 새로 연결하고 voice_clients에 저장합니다.
  4. await search_youtube(query): 입력받은 query로 YouTube를 검색합니다.
  5. 검색 결과(song_info)가 없으면 오류 메시지를 보냅니다.
  6. 해당 서버의 큐(music_queues[guild_id])가 없으면 새로 만듭니다.
  7. 재생 로직:
    • 현재 재생 중인 곡이 없거나(guild_id not in current_songs) 또는 음성 클라이언트가 실제로 아무것도 재생하고 있지 않으면(not voice_client.is_playing()), await play_song(voice_client, song_info, guild_id)을 호출하여 바로 재생합니다.
      • 재생 성공 시, 현재 재생 정보를 Embed로 만들어 MusicView 버튼들과 함께 보여줍니다.
    • 그렇지 않으면 (이미 뭔가 재생 중이면), 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시간 운영하려면 크레딧을 충전해야 합니다.

  1. 대시보드 접속: 디스호스트 웹사이트에 로그인하여 대시보드로 이동합니다.
  2. 크레딧 추가 메뉴: 왼쪽 사이드바에서 '크레딧 추가' 메뉴를 클릭합니다.
  3. 충전 금액 선택:
    • 사전 설정된 금액(10,000원, 50,000원, 100,000원 등) 중 선택하거나
    • 직접 원하는 금액을 입력합니다 (최소 1,000원)
  4. 충전 방법 선택: 현재 지원되는 충전 방법은 다음과 같습니다:
    • 계좌이체: 실시간 계좌이체 (수수료 없음)
      • 입금자명을 정확히 입력
      • 표시된 계좌로 정확한 금액을 5분 이내에 입금
      • '입금 완료 확인하기' 버튼 클릭하여 확인
    • 문화상품권: 문화상품권 핀번호 입력 (10% 수수료 적용)
      • 상품권 핀번호를 정확히 입력
      • 실제 충전 크레딧 = 상품권 금액의 90%

3. 봇 관리 패널 연동

디스호스트에서 봇을 관리하기 위해서는 봇 관리 패널 계정을 연동해야 합니다.

패널 연동 절차

  1. 대시보드 접속: 디스호스트 웹사이트 로그인 후 대시보드 진입
  2. 사용자 정보 탭: 좌측 사이드바에서 '사용자 정보' 메뉴 선택
  3. 계정 연동 설정: 'Pterodactyl 계정 연동' 섹션에서 다음 정보 입력
    • 이메일: Pterodactyl 계정 전용 이메일 주소
    • 사용자명: 패널 로그인 ID
    • 비밀번호: 강력한 보안 비밀번호 (특수문자, 대소문자, 숫자 조합)
  4. 계정 생성: '계정 생성' 버튼 클릭하여 연동 완료

중요 보안 고려사항

경고: Pterodactyl 계정은 연동 해제가 불가능하며, 로그인 정보는 대시보드에서 재확인할 수 없습니다. 입력한 정보를 안전한 장소에 기록하여 보관하시기 바랍니다.

4. 봇 서버 생성

크레딧 충전과 패널 연동이 완료된 후 봇 서버를 생성합니다.

서버 생성 절차

  1. 봇 관리 페이지: 대시보드에서 '내 봇' 탭으로 이동
  2. 새 서버 추가: '새 서버 추가하기' 버튼 클릭
  3. 개발 환경 선택: Python 언어 환경 선택
  4. 리소스 플랜 선택: 봇 규모에 적합한 플랜 선택
    • Starter: 소규모 봇을 위한 기본 플랜
    • Standard: 중간 규모 봇을 위한 표준 플랜
    • Enterprise: 대규모 봇을 위한 고급 플랜
  5. 서버 구매: '봇 구매하기' 버튼을 통해 결제 진행

서버 생성 대기

서버 생성 프로세스는 일반적으로 1-3분 소요되며, 완료 시 성공 메시지가 표시됩니다.

5. Pterodactyl 패널을 통한 봇 배포

패널 접속

  1. 패널 접속: https://panel.dishost.kr로 이동
  2. 로그인: 앞서 설정한 사용자명과 비밀번호로 인증
  3. 서버 선택: 생성된 봇 서버를 목록에서 선택

봇 코드 업로드

업로드 절차

  1. 파일 관리자: 서버 관리 페이지에서 'Files' 탭 선택
  2. 파일 생성: 새 파일 버튼 클릭하여 파일 생성
  3. 봇 붙여넣기: 만든 봇을 붙여넣기
  4. 저장: 파일 생성 버튼을 눌러 저장, 이름은 app.py로 생성

봇 시작 설정

  1. Startup 탭: 서버 관리 페이지에서 'Startup' 탭 선택
  2. 시작 파일 설정: 'STARTUP FILE' 필드에 메인 Python 파일명 입력 (예: app.py)
  3. 의존성 설정: 'Additional Python packages' 필드에 필요한 패키지 입력
    • discord.py
    • yt-dlp
    • PyNaCl
  4. 설정 저장: 'Save' 버튼으로 변경사항 저장

봇 실행

  1. 콘솔 접속: 'Console' 탭으로 이동
  2. 서버 시작: 'Start' 버튼 클릭하여 봇 실행
  3. 로그 모니터링: 콘솔 출력을 통해 정상 시작 확인
  4. 상태 검증: Discord에서 봇의 온라인 상태 확인

운영 고려사항

  • 자동 연장 없음: 봇 서버는 자동 연장되지 않으므로 만료 전 수동 연장 필요
  • 만료 후 유예기간: 서버 데이터는 만료 후 7일간 보존
  • 연장 권장시점: 만료일 최소 3일 전 연장 처리

정상적으로 실행되면 봇이 온라인 상태로 표시되며, Discord 서버에서 슬래시 명령어를 사용할 수 있습니다.

'봇 개발 팁 > Discord.py' 카테고리의 다른 글

Discord.py Components V2 사용 가이드 (신형 임베드, 줄 나누기, 임베드 내에 버튼 등)  (0) 2025.12.06
'봇 개발 팁/Discord.py' 카테고리의 다른 글
  • Discord.py Components V2 사용 가이드 (신형 임베드, 줄 나누기, 임베드 내에 버튼 등)
디스호스트
디스호스트
쉽고 안정적인 디스코드 봇 호스팅 서비스, 디스호스트의 기술 블로그입니다. 디스호스트는 24시간 구동되는 서버를 통해 디스코드 봇을 대신 구동시켜 드리는 서비스를 제공하고 있습니다.
  • 디스호스트
    디스호스트 기술 블로그
    디스호스트
  • 블로그 메뉴

    • 홈
    • 디스호스트 사용 가이드
    • 디스코드 봇 호스팅, 24시간 서버 구동
    • 분류 전체보기 (49) N
      • 디스코드 (10)
      • 디스호스트 가이드 (12)
      • 봇 개발 팁 (12) N
        • Discord.js (9) N
        • Discord.py (2) N
      • DiscordJS 개발 튜토리얼 (15)
  • 링크

    • 디스호스트
  • hELLO· Designed By정상우.v4.10.3
디스호스트
Discord.py로 디스코드 음악 봇 만들기: 디스호스트로 24시간 호스팅까지!
상단으로

티스토리툴바