- Core bot with slash command tree (discord.py 2.7.x compatible) - Commands: /ping, /info, /hello, /emoji, /app-emoji - Admin: /upload-emoji, /list-emojis, /delete-emoji - GIF emote detection and reaction handling - Message reactions with percentage-based triggers and cooldowns - Voice/music cog with YTDLSource, skip, volume, pause, resume, stop - Global error handler and voice state change logging - Environment config via .env (DISCORD_TOKEN, COMMAND_PREFIX, etc.)
297 lines
9.7 KiB
Python
297 lines
9.7 KiB
Python
"""Voice channel music handler with YTDLSource, FFmpegOpusAudio, queue, and controls."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import discord
|
|
from discord.ext import commands
|
|
from discord.ext.commands import Cog, Context, has_permissions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ─── Configuration ───────────────────────────────────────────────────────────
|
|
|
|
PREFIX = "!"
|
|
GUILD_IDS = [] # Set in bot.json or env var
|
|
CHANNEL_ID = None # Set in bot.json or env var
|
|
|
|
# ─── Music Cog ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class MusicCog(Cog):
|
|
"""Music commands for voice channels."""
|
|
|
|
def __init__(self, bot: commands.Bot) -> None:
|
|
self.bot = bot
|
|
self._current_queue: list[discord.AudioTrack] = []
|
|
self._is_playing: bool = False
|
|
self._voice_channel: Optional[discord.VoiceChannel] = None
|
|
self._playing_lock: asyncio.Lock = asyncio.Lock()
|
|
self._stop_requested: bool = False
|
|
self._current_volume: int = 100
|
|
self._is_paused: bool = False
|
|
|
|
def cog_unload(self) -> None:
|
|
"""Clean up on cog unload."""
|
|
if self._voice_channel:
|
|
self._voice_channel.disconnect()
|
|
|
|
async def _ensure_voice(self, ctx: Context) -> None:
|
|
"""Ensure the bot is in a voice channel."""
|
|
if self._voice_channel is None:
|
|
channel = await ctx.author.voice.channel.connect()
|
|
self._voice_channel = channel
|
|
self._is_playing = True
|
|
elif not self._voice_channel.is_connected():
|
|
self._voice_channel = await ctx.author.voice.channel.connect()
|
|
|
|
async def _disconnect(self) -> None:
|
|
"""Disconnect from the voice channel."""
|
|
if self._voice_channel:
|
|
try:
|
|
await self._voice_channel.disconnect()
|
|
except discord.DiscordError:
|
|
pass
|
|
self._voice_channel = None
|
|
self._is_playing = False
|
|
self._stop_requested = False
|
|
|
|
async def play(self, ctx: Context, source: str) -> None:
|
|
"""Play a track from a source (URL or file)."""
|
|
if not self._is_playing:
|
|
await self._ensure_voice(ctx)
|
|
|
|
try:
|
|
track = await YTDLSource.from_url(source, source_type="youtube", loop=False)
|
|
self._current_queue.append(track)
|
|
await self._play_next()
|
|
except Exception as e:
|
|
logger.error(f"Error playing source {source}: {e}")
|
|
await ctx.send(f"Error playing: {e}")
|
|
|
|
async def _play_next(self) -> None:
|
|
"""Play the next track in the queue."""
|
|
if not self._current_queue:
|
|
await self._disconnect()
|
|
return
|
|
|
|
track = self._current_queue.pop(0)
|
|
|
|
try:
|
|
if self._voice_channel:
|
|
await self._voice_channel.play(track, volume=self._current_volume / 100.0)
|
|
self._is_playing = True
|
|
except Exception as e:
|
|
logger.error(f"Error playing track: {e}")
|
|
await self._play_next()
|
|
|
|
async def skip(self, ctx: Context) -> None:
|
|
"""Skip the current track."""
|
|
if not self._is_playing:
|
|
await ctx.send("Not playing anything.")
|
|
return
|
|
|
|
try:
|
|
await self._voice_channel.stop()
|
|
self._current_queue.clear()
|
|
self._is_playing = False
|
|
await ctx.send("Skipped.")
|
|
except Exception as e:
|
|
logger.error(f"Error skipping: {e}")
|
|
await ctx.send(f"Error: {e}")
|
|
|
|
async def volume(self, ctx: Context, *, volume: int = 100) -> None:
|
|
"""Set the volume (0-100)."""
|
|
if not self._is_playing:
|
|
await ctx.send("Not playing anything.")
|
|
return
|
|
|
|
if volume < 0 or volume > 100:
|
|
await ctx.send("Volume must be between 0 and 100.")
|
|
return
|
|
|
|
self._current_volume = volume
|
|
await ctx.send(f"Volume set to {volume}%")
|
|
|
|
async def pause(self, ctx: Context) -> None:
|
|
"""Pause playback."""
|
|
if not self._is_playing:
|
|
await ctx.send("Not playing anything.")
|
|
return
|
|
|
|
self._is_paused = True
|
|
await ctx.send("Playback paused.")
|
|
|
|
async def resume(self, ctx: Context) -> None:
|
|
"""Resume playback."""
|
|
if self._is_paused:
|
|
self._is_paused = False
|
|
await ctx.send("Playback resumed.")
|
|
else:
|
|
await ctx.send("Already playing.")
|
|
|
|
async def stop(self, ctx: Context) -> None:
|
|
"""Stop and disconnect."""
|
|
await self._disconnect()
|
|
await ctx.send("Stopped and disconnected.")
|
|
|
|
async def queue(self, ctx: Context) -> None:
|
|
"""Show the current queue."""
|
|
if not self._current_queue:
|
|
await ctx.send("Queue is empty.")
|
|
return
|
|
|
|
tracks = []
|
|
for i, track in enumerate(self._current_queue):
|
|
tracks.append(f"{i+1}. {track.title} — {track.author}")
|
|
await ctx.send("\n".join(tracks))
|
|
|
|
|
|
# ─── YTDLSource ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class YTDLSource(discord.PCMVolumeTransformer):
|
|
"""Audio source from youtube-dl."""
|
|
|
|
def __init__(self, data, *, is_live=False, loop=False):
|
|
super().__init__()
|
|
self.title = data["title"]
|
|
self.url = data["url"]
|
|
self.is_live = is_live
|
|
self.duration = data["duration"] if "duration" in data else None
|
|
self.author = data.get("uploader", "Unknown")
|
|
self.loop = loop
|
|
self._duration = data["duration"] if "duration" in data else None
|
|
|
|
@classmethod
|
|
async def from_url(cls, url, source_type="youtube", loop=False):
|
|
"""Create a source from a URL."""
|
|
import youtube_dl
|
|
|
|
ydl_opts = {
|
|
"format": "bestaudio/best",
|
|
"noplaylist": True,
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
}
|
|
|
|
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(url, download=False)
|
|
|
|
if info is None:
|
|
raise ValueError("No video info found.")
|
|
|
|
if info.get("url") is None:
|
|
raise ValueError("No URL found in video info.")
|
|
|
|
if info.get("duration") is None:
|
|
raise ValueError("No duration found in video info.")
|
|
|
|
data = {
|
|
"url": info["url"],
|
|
"title": info["title"],
|
|
"duration": info["duration"],
|
|
"uploader": info.get("uploader", "Unknown"),
|
|
}
|
|
|
|
return cls(data, is_live=info.get("is_live", False), loop=loop)
|
|
|
|
@property
|
|
def duration(self):
|
|
"""Return the duration in seconds."""
|
|
return self._duration
|
|
|
|
@duration.setter
|
|
def duration(self, value):
|
|
"""Set the duration."""
|
|
self._duration = value
|
|
|
|
@property
|
|
def is_live(self):
|
|
"""Return whether this is a live stream."""
|
|
return self._is_live
|
|
|
|
@is_live.setter
|
|
def is_live(self, value):
|
|
"""Set whether this is a live stream."""
|
|
self._is_live = value
|
|
|
|
|
|
# ─── FFmpegOpusAudio ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class FFmpegOpusAudio(discord.PCMVolumeTransformer):
|
|
"""Audio source from FFmpegOpusAudio."""
|
|
|
|
def __init__(self, data, *, is_live=False, loop=False):
|
|
super().__init__()
|
|
self.title = data["title"]
|
|
self.url = data["url"]
|
|
self.is_live = is_live
|
|
self.duration = data["duration"] if "duration" in data else None
|
|
self.author = data.get("uploader", "Unknown")
|
|
self.loop = loop
|
|
self._duration = data["duration"] if "duration" in data else None
|
|
|
|
@classmethod
|
|
async def from_url(cls, url, source_type="youtube", loop=False):
|
|
"""Create a source from a URL using FFmpegOpusAudio."""
|
|
import youtube_dl
|
|
|
|
ydl_opts = {
|
|
"format": "bestaudio/best",
|
|
"noplaylist": True,
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
}
|
|
|
|
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(url, download=False)
|
|
|
|
if info is None:
|
|
raise ValueError("No video info found.")
|
|
|
|
if info.get("url") is None:
|
|
raise ValueError("No URL found in video info.")
|
|
|
|
if info.get("duration") is None:
|
|
raise ValueError("No duration found in video info.")
|
|
|
|
data = {
|
|
"url": info["url"],
|
|
"title": info["title"],
|
|
"duration": info["duration"],
|
|
"uploader": info.get("uploader", "Unknown"),
|
|
}
|
|
|
|
return cls(data, is_live=info.get("is_live", False), loop=loop)
|
|
|
|
@property
|
|
def duration(self):
|
|
"""Return the duration in seconds."""
|
|
return self._duration
|
|
|
|
@duration.setter
|
|
def duration(self, value):
|
|
"""Set the duration."""
|
|
self._duration = value
|
|
|
|
@property
|
|
def is_live(self):
|
|
"""Return whether this is a live stream."""
|
|
return self._is_live
|
|
|
|
@is_live.setter
|
|
def is_live(self, value):
|
|
"""Set whether this is a live stream."""
|
|
self._is_live = value
|
|
|
|
|
|
# ─── Bot Integration ─────────────────────────────────────────────────────────
|
|
|
|
|
|
async def setup(bot: commands.Bot) -> None:
|
|
"""Register the music cog with the bot."""
|
|
await bot.add_cog(MusicCog(bot))
|