ディスコード音楽ボットの作成

Pythonを使用してディスコードで音楽を再生するボットを作成するプロセスを紹介します。まずは、ディスコードボットの作成とGoogle Cloud PlatformでYouTube APIキーを取得する方法から始めます。

**ディスコードボットの作成方法**
1. ディスコード開発者ポータルへのアクセス: [Discord Developer Portal](https://discord.com/developers/)にアクセスします。
1. 新しいアプリケーションの作成: 「New Application」ボタンをクリックして新しいアプリケーションを作成します。
1. ボットの追加: 「Bot」セクションに移動し、「Add Bot」をクリックしてボットをアプリケーションに追加します。
1. トークンの取得: ボットのトークンを取得し、安全な場所に保管します。このトークンは後ほどコード内で使用します。

**GCPでYouTube APIキーを取得**
1. Google Cloud Platformにログイン: [Google Cloud Console](https://console.cloud.google.com/)にアクセスしてログインします。
1. 新しいプロジェクトの作成: 「プロジェクトを作成」ボタンをクリックして新しいプロジェクトを作成します。
1. YouTube Data API v3の有効化: 「APIとサービス」ダッシュボードで「APIとサービスを有効化」をクリックし、YouTube Data API v3を検索して有効化します。
1. APIキーの作成: 「認証情報」ページに移動し、「認証情報を作成」ボタンからAPIキーを作成します。
:::note warn
既に作成されているプロジェクトから「YouTube Data API v3」を有効化した場合、APIキーが使えなくなる場合があり、その際には新しいプロジェクトからAPIキーを作成してやり直す
:::

**FFmpegのインストール**

FFmpegは、さまざまなオーディオおよびビデオ形式を扱うために必要な無料のソフトウェアです。ディスコードボットで音楽を再生するには、FFmpegがシステムにインストールされている必要があります。

**ライブラリのインストール**
1. `discord.py` : pythonを利用してディスコードボットを作る際に必要
1. `google-api-python-client` : youtube APIとの通信のために必要
1. `PyNaCl` : pythonからネットワーク通信のセキュリティするためのライブラリ、ディスコードボットから音声機能を使用するために必要
1. `yt-dpl` : youtube urlから音楽をダウンロードするために必要
1. `python-dontenv` : トークンや環境変数の管理のために必要

```
pip install discord.py google-api-python-client yt-dpl PyNaCl python-dotenv
```

**.envファイル生成**
プロジェクトのルートディレクトリに`.env`ファイルを作成し、環境変数を設定
```
DISCORD_TOKEN=your_discord_bot_token
YOUTUBE_API_KEY=your_youtube_api_key
FFMPEG_PATH=path_to_ffmpeg(ffmpeg.exeファイルがあるディレクトリ)
```

**pythonから.env使用**

```python:python
import os
from dotenv import load_dotenv

load_dotenv()

discord_token = os.getenv('DISCORD_TOKEN')
youtube_api_key = os.getenv('YOUTUBE_API_KEY')
ffmpeg_path = os.getenv('FFMPEG_PATH')
dl_path = os.getenv('MUSIC_PATH')
```

:::note warn
- `.env`ファイルは敏感な情報が含まれる可能性があるため、絶対に公開ストレージにアップロードしないでください。
-  境変数は、重要な設定情報やパスワードなどを保存するために使用されます。
:::


**Discord Intentsの設定**
```python:python
intents = discord.Intents.default()  # intents active
intents.messages = True  # set react to message event
intents.guilds = True  # set react to server(guild) event
intents.message_content = True  # set read message
```

```python:python
intents = discord.Intents.default()  # intents active
```
- `discord.Intents.default()`は、Discordのイベントに反応するために必要なデフォルトの`Intents`オブジェクトを作成します。`Intents`はボットがどの種類のイベント（例えばメッセージ、リアクション、メンバーの更新など）に反応するかを制御するために使用されます。
```python:python
intents.messages = True  # set react to message event
```
- この行はボットがメッセージイベントに反応することを許可します。つまり、ユーザーが送信したメッセージに対してボットが反応できるようになります
```python:python
intents.guilds = True  # set react to server(guild) event
```
- ここでは、ボットがサーバー（ギルド）のイベントに反応できるように設定しています。サーバーの作成、削除、更新などのイベントがこれに含まれます。
```python:python
intents.message_content = True  # set read message
```
- この行はボットにメッセージの内容を読む権限を与えます。これにより、ボットは送信されたメッセージの内容を解析し、特定のコマンドやキーワードに基づいてアクションを取ることができます。

**ボットの初期化**
```python:python
bot = commands.Bot(command_prefix='!', intents=intents)
```
- この行は`commands.Bot`クラスのインスタンスを作成し、ボットを初期化します。ここで設定される`command_prefix='!'`は、ボットのコマンドがどの文字列で始まるかを定義します。この例では、ユーザーが`!`を先頭に付けたメッセージをボットコマンドとして認識します。
- `intents=intents`は、上記で設定した`Intents`オブジェクトをボットに適用します。これにより、ボットは設定したイベントに対してのみ反応するようになります。

**YouTube API検索関数の定義**
```python:python
# function , YouTube api search
async def youtube_search(ctx, query):
    youtube = build('youtube', 'v3', developerKey=youtube_api_key)

    request = youtube.search().list(
        q=query,
        part='snippet',
        type='music',
        maxResults=1
    )
    response = request.execute()

    if response['items']:
        return f'https://www.youtube.com/watch?v={response["items"][0]["id"]["videoId"]}'
    else:
        await ctx.send(f"Failed... no search result")
        return None
```
```python:python
async def youtube_search(ctx, query):
```
- ここで定義されている`youtube_search`関数は、非同期（async）関数です。これは、関数が非同期操作を行うことを意味し、主にネットワークリクエストなどの待ち時間が発生する処理に適しています。
- `ctx`はコマンドが実行されたコンテキスト（Discordのメッセージやチャンネルなど）を表し、`query`はYouTubeで検索するためのクエリ文字列です。
```python:python
youtube = build('youtube', 'v3', developerKey=youtube_api_key)
```
- この行は、`googleapiclient.discovery`モジュールの`build`関数を使用して、YouTube Data API v3のクライアントを作成します。
- `developerKey=youtube_api_key`は、環境変数から取得したYouTube APIキーを使用して、APIリクエストの認証を行います。
```python:python
request = youtube.search().list(
    q=query,
    part='snippet',
    type='music',
    maxResults=1
)
```
- `youtube.search().list`メソッドを使って、検索リクエストを構築します。
- `q=query`は検索クエリを指定します。
- `part='snippet'`は、検索結果に含まれるデータの種類を指定します。ここでは、動画の概要（タイトル、説明、サムネイルなど）を取得します。
- `type='music'`は、検索結果を音楽カテゴリの動画に限定します。
- `maxResults=1`は、検索結果を1つだけ取得することを指定します。
```python:python
response = request.execute()
```
- `request.execute()`メソッドで検索リクエストを実行し、結果を`response`に格納します。
```python:python
if response['items']:
    return f'https://www.youtube.com/watch?v={response["items"][0]["id"]["videoId"]}'
else:
    await ctx.send(f"Failed... no search result")
    return None
```
- `if response['items']:` で検索結果が存在するかを確認します。
- 結果がある場合、最初の動画（`response["items"][0]`）のURLを生成して返します。
- 結果がない場合、`await ctx.send`を使用してDiscordのチャンネルに「検索結果が見つかりません」というメッセージを送信し、`None`を返します。

**`!add` プレイリストに追加メソッド**
```python:python
# add music from YouTube
@bot.command(name='add')
async def search(ctx, *, query):
    video_id = await youtube_search(ctx, query)
    if video_id:
        ydl_opts = {
            'headers': {
                'User-Agent': 'Mozilla/5.0'
            },
            'format': 'bestaudio/best',
            'outtmpl': f'{dl_path}/%(title)s.%(ext)s'
        }
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(video_id, download=True)
            info = ydl.sanitize_info(info)
            
            if info['title'] and info['ext']:
                music_path = f"{dl_path}/{info['title']}.{info['ext']}"
                musicData = {'name': query, 'url': music_path}
                music_list.put(musicData)
                await ctx.send(f"add music {query}")
            else:
                await ctx.send("Failed... to add music")

    else:
        await ctx.send('Failed... no search data')

```
```python:python
@bot.command(name='add')
async def search(ctx, *, query):
```
- ここで定義されている`search`関数は、Discordボットの`add`コマンドを処理します。
- `ctx`はコマンドが実行されたコンテキストを表し、`query`はYouTubeで検索するためのクエリ文字列です。
- この関数は非同期です。これは、関数が非同期操作（ネットワークリクエストなど）を含むことを意味します。
```python:python
video_id = await youtube_search(ctx, query)
```
- `youtube_search`関数を非同期的に呼び出して、与えられた`query`でYouTubeを検索します。検索結果から動画のIDを取得します。
```python:python
if video_id:
        ydl_opts = {
            'headers': {
                'User-Agent': 'Mozilla/5.0'
            },
            'format': 'bestaudio/best',
            'outtmpl': f'{dl_path}/%(title)s.%(ext)s'
        }
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(video_id, download=True)
            info = ydl.sanitize_info(info)
            ...
```
- `if video_id`: は、動画IDが存在するかどうかをチェックします。
- `ydl_opts` は、yt_dlpに渡すオプションを格納する辞書です。
- `headers`: {'User-Agent': 'Mozilla/5.0'} は、ダウンロードリクエストにユーザーエージェントを設定します。これは、YouTubeがリクエストを通常のブラウザからのものとして認識するのに役立ちます。
- `format`: 'bestaudio/best' は、利用可能な最高音質のオーディオをダウンロードすることを指定します。
- `outtmpl`: f'{dl_path}/%(title)s.%(ext)s' は、ダウンロードされたファイルの保存形式とパスを指定します。dl_pathはダウンロードパスで、ファイル名はYouTube動画のタイトルに基づいています。
```python:python

            if info['title'] and info['ext']:
                music_path = f"{dl_path}/{info['title']}.{info['ext']}"
                musicData = {'name': query, 'url': music_path}
                music_list.put(musicData)
                await ctx.send(f"add music {query}")
            else:
                await ctx.send("Failed... to add music")

```
このコードは、ユーザーが指定したYouTube動画から音声をダウンロードし、その音声をプレイリストに追加する機能を提供します。エラーハンドリングも含まれており、何らかの問題が発生した場合には適切なメッセージでユーザーに通知されます。

**`!play` プレイリストに追加メソッド**
```python:python
# play music
@bot.command(name='play')
async def play(ctx):
    if not music_list.empty():
        if not ctx.voice_client:
            await ctx.author.voice.channel.connect()
        else:
            await ctx.send('Failed... already played')
            return
        await start_playback(ctx)
    else:
        await ctx.send('Failed... no data')

```
```python:python
# play music
@bot.command(name='play')
async def play(ctx):
```
- ここで定義されているplay関数は、Discordボットのplayコマンドを処理します。
- `ctx`はコマンドが実行されたコンテキストを表します。
- この関数も非同期です。つまり、関数内の処理が非同期操作を含むことを意味します。
```python:python
if not music_list.empty():
```
- この行は、プレイリスト`music_list`に音楽データがあるかどうかを確認します。
```python:python
if not ctx.voice_client:
    await ctx.author.voice.channel.connect()
```
- この部分では、ボットがすでにボイスチャンネルに接続していないかをチェックします。
- もしボットがボイスチャンネルに接続していなければ、コマンドを実行したユーザーがいるボイスチャンネルに接続します。
```python:python
else:
    await ctx.send('Failed... already played')
    return
```
- もしボットがすでにボイスチャンネルに接続している場合、エラーメッセージを送信して処理を終了します
```python:python
await start_playback(ctx)
```
- `start_playback`関数を非同期で呼び出して、プレイリストからの音楽再生を開始します。

**音楽を再生メソッド（start_playback）**


このコードは、Discordボットが音楽を再生するためのstart_playback関数です。この関数は、プレイリストにある音楽を順番に再生し、各曲が終わった後に次の曲を再生します。それぞれの部分について詳しく説明します。

```python:python
# play music
async def start_playback(ctx):
    while music_list.qsize() > 0:
        music_data = music_list.get()
        audio_source = discord.FFmpegPCMAudio(executable=ffmpeg_path, source=music_data.get('url', 'noData'))
        ctx.voice_client.play(audio_source)
        await ctx.send(f"play music: {music_data.get('name', 'noData')}")
        while ctx.voice_client.is_playing():
            await asyncio.sleep(1)
        ctx.voice_client.stop()
        os.remove(music_data['url'])
    await ctx.voice_client.disconnect()
    await ctx.send('End play music')
```
```python:python
while ctx.voice_client.is_playing():
            await asyncio.sleep(1)
```
- while ctx.voice_client.is_playing(): このループは、音楽が再生中である間続きます。await asyncio.sleep(1)はループを1秒ごとに一時停止し、サーバーへの負荷を軽減します。


***全体コード***
```python:python
import asyncio
import discord
import yt_dlp
from discord.ext import commands
import os
from dotenv import load_dotenv
from googleapiclient.discovery import build
from queue import Queue

# dot env load
load_dotenv()

# get token
discord_token = os.getenv('DISCORD_BOT_TOKEN')
youtube_api_key = os.getenv('YOUTUBE_API_KEY')
ffmpeg_path = os.getenv('FFMPEG_Path')
dl_path = os.getenv('MUSIC_PATH')

if not discord_token or not youtube_api_key or not ffmpeg_path or not dl_path:
    print("Error .env")
    exit(1)

# play list
music_list = Queue()

# discord
intents = discord.Intents.default()  # intents active
intents.messages = True  # set react to message event
intents.guilds = True  # set react to server(guild) event
intents.message_content = True  # set read message

# ! + query
bot = commands.Bot(command_prefix='!', intents=intents)


# function , YouTube api search
async def youtube_search(ctx, query):
    youtube = build('youtube', 'v3', developerKey=youtube_api_key)

    request = youtube.search().list(
        q=query,
        part='snippet',
        type='music',
        maxResults=1
    )
    response = request.execute()

    if response['items']:
        return f'https://www.youtube.com/watch?v={response["items"][0]["id"]["videoId"]}'
    else:
        await ctx.send(f"Failed... no search result")
        return None


# play music
async def start_playback(ctx):
    while music_list.qsize() > 0:
        music_data = music_list.get()
        audio_source = discord.FFmpegPCMAudio(executable=ffmpeg_path, source=music_data.get('url', 'noData'))
        ctx.voice_client.play(audio_source)
        await ctx.send(f"play music: {music_data.get('name', 'noData')}")
        while ctx.voice_client.is_playing():
            await asyncio.sleep(1)
        ctx.voice_client.stop()
        os.remove(music_data['url'])
    await ctx.voice_client.disconnect()
    await ctx.send('End play music')


# client setting
@bot.event
async def on_ready():
    print(f'Logged in as {bot.user.name}')


# add reaction to hello
@bot.command(name='hello')
async def hello(ctx):
    await ctx.send('hello!')


# add music from YouTube
@bot.command(name='add')
async def search(ctx, *, query):
    video_id = await youtube_search(ctx, query)
    if video_id:
        ydl_opts = {
            'headers': {
                'User-Agent': 'Mozilla/5.0'
            },
            'format': 'bestaudio/best',
            'outtmpl': f'{dl_path}/%(title)s.%(ext)s'
        }
        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            info = ydl.extract_info(video_id, download=True)
            info = ydl.sanitize_info(info)
            if info['title'] and info['ext']:
                music_path = f"{dl_path}/{info['title']}.{info['ext']}"
                musicData = {'name': query, 'url': music_path}
                music_list.put(musicData)
                await ctx.send(f"add music {query}")
            else:
                await ctx.send("Failed... to add music")

    else:
        await ctx.send('Failed... no search data')


# play music
@bot.command(name='play')
async def play(ctx):
    if not music_list.empty():
        if not ctx.voice_client:
            await ctx.author.voice.channel.connect()
        else:
            await ctx.send('Failed... already played')
            return
        await start_playback(ctx)
    else:
        await ctx.send('Failed... no data')


bot.run(discord_token)
```