Skip to content

Commit cc32a29

Browse files
AmbratolmAmbratolm
authored andcommitted
✨🔊 Added audio playback features (wip...)
1 parent bb8ef45 commit cc32a29

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed

bot/cogs/audio_cog.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import asyncio
2+
3+
from discord import (
4+
AudioSource,
5+
FFmpegPCMAudio,
6+
Guild,
7+
Interaction,
8+
Member,
9+
VoiceClient,
10+
app_commands,
11+
utils,
12+
)
13+
from discord.ext.commands import GroupCog
14+
15+
from bot.main import ActBot
16+
from bot.ui.embed import EmbedX
17+
from utils.audio import MediaSource, YouTubeSource
18+
from utils.log import logger
19+
20+
log = logger(__name__)
21+
22+
23+
# ----------------------------------------------------------------------------------------------------
24+
# * Audio Player
25+
# ----------------------------------------------------------------------------------------------------
26+
class DiscordAudioPlayer:
27+
def __init__(self, guild: Guild):
28+
self.guild = guild
29+
self.queue: list[MediaSource] = []
30+
self.current: MediaSource | None = None
31+
self.playing = False
32+
self.loop = asyncio.get_event_loop()
33+
34+
async def play_next(self, voice_client: VoiceClient) -> bool:
35+
if not self.queue or not voice_client:
36+
self.playing = False
37+
await voice_client.disconnect()
38+
return False
39+
40+
self.current = self.queue.pop(0)
41+
self.playing = True
42+
43+
try:
44+
source = FFmpegPCMAudio(
45+
self.current.url,
46+
before_options="-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
47+
)
48+
voice_client.play(
49+
source,
50+
after=lambda e: self.loop.create_task(self.play_next(voice_client)),
51+
)
52+
return True
53+
except Exception as e:
54+
log.error(f"Error playing audio: {e}")
55+
self.playing = False
56+
await self.play_next(voice_client)
57+
return False
58+
59+
def add_to_queue(self, sources: list[MediaSource]):
60+
self.queue.extend(sources)
61+
62+
def clear_queue(self):
63+
self.queue.clear()
64+
65+
def stop(self):
66+
self.clear_queue()
67+
self.playing = False
68+
self.current = None
69+
70+
71+
# ----------------------------------------------------------------------------------------------------
72+
# * Audio Cog
73+
# ----------------------------------------------------------------------------------------------------
74+
class AudioCog(
75+
GroupCog, group_name="audio", description="Provide audio playback interface."
76+
):
77+
78+
def __init__(self, bot: ActBot):
79+
self.bot = bot
80+
self.queue: list[AudioSource] = []
81+
self.current: AudioSource | None = None
82+
self.playing = False
83+
self.loop = asyncio.get_event_loop()
84+
self.audio_players: dict[int, DiscordAudioPlayer] = {}
85+
self.source_providers = {
86+
"youtube": YouTubeSource,
87+
# Add more providers here (e.g., 'soundcloud': SoundCloudSource)
88+
}
89+
90+
# ----------------------------------------------------------------------------------------------------
91+
92+
@GroupCog.listener()
93+
async def on_voice_state_update(self, member: Member, before, after):
94+
if member == self.bot.user and not after.channel: # Bot was disconnected
95+
audio_player = self.get_audio_player(member.guild)
96+
if audio_player:
97+
audio_player.stop()
98+
self.audio_players.pop(member.guild.id, None)
99+
100+
# ----------------------------------------------------------------------------------------------------
101+
102+
@app_commands.command(
103+
name="play", description="Play audio from a YouTube URL or search query"
104+
)
105+
@app_commands.describe(query="YouTube URL or search query")
106+
async def play(self, interaction: Interaction, query: str):
107+
await interaction.response.defer(ephemeral=True)
108+
109+
voice_client = await self.ensure_voice(interaction)
110+
if not voice_client:
111+
return
112+
113+
audio_player = self.get_audio_player(interaction.guild)
114+
if not audio_player:
115+
return
116+
117+
# Determine source provider (default to YouTube)
118+
source_provider = self.source_providers["youtube"]
119+
120+
# Extract sources
121+
sources = await source_provider.from_url(query, loop=self.bot.loop)
122+
123+
if not sources:
124+
await interaction.followup.send("No audio sources found!", ephemeral=True)
125+
return
126+
127+
audio_player.add_to_queue(sources)
128+
129+
if not audio_player.playing:
130+
await audio_player.play_next(voice_client)
131+
132+
await interaction.followup.send(
133+
f"Added {len(sources)} track(s) to queue. Now playing: {audio_player.current.title}"
134+
if audio_player.current
135+
else f"Added {len(sources)} track(s) to queue."
136+
)
137+
138+
@app_commands.command(name="stop", description="Stop playback and clear queue")
139+
async def stop(self, interaction: Interaction):
140+
voice_client = await self.ensure_voice(interaction)
141+
if not voice_client:
142+
return
143+
144+
audio_player = self.get_audio_player(interaction.guild)
145+
if audio_player:
146+
audio_player.stop()
147+
148+
if voice_client.is_connected():
149+
await voice_client.disconnect()
150+
151+
await interaction.response.send_message("Stopped playback and cleared queue.")
152+
153+
@app_commands.command(name="skip", description="Skip the current track")
154+
async def skip(self, interaction: Interaction):
155+
voice_client = await self.ensure_voice(interaction)
156+
if not voice_client:
157+
return
158+
159+
player = self.get_audio_player(interaction.guild)
160+
if not player.playing:
161+
await interaction.response.send_message(
162+
"Nothing is playing!", ephemeral=True
163+
)
164+
return
165+
166+
voice_client.stop()
167+
await interaction.response.send_message("Skipped current track.")
168+
169+
@app_commands.command(name="queue", description="Show the current queue")
170+
async def queue(self, interaction: Interaction):
171+
player = self.get_audio_player(interaction.guild)
172+
if not player.queue and not player.current:
173+
await interaction.response.send_message("Queue is empty!", ephemeral=True)
174+
return
175+
176+
embed = EmbedX.info(title="Queue", color=discord.Color.blue())
177+
if player.current:
178+
# Truncate current track title to avoid exceeding field limits
179+
current_title = (
180+
player.current.title[:200] + "..."
181+
if len(player.current.title) > 200
182+
else player.current.title
183+
)
184+
embed.add_field(
185+
name="Now Playing",
186+
value=f"{current_title} ({player.current.url})",
187+
inline=False,
188+
)
189+
190+
if player.queue:
191+
# Limit tracks per field to avoid exceeding 1024 chars
192+
max_field_length = 1024
193+
max_tracks_per_field = 5 # Adjust based on typical title lengths
194+
queue_chunks = [
195+
player.queue[i : i + max_tracks_per_field]
196+
for i in range(0, len(player.queue), max_tracks_per_field)
197+
]
198+
199+
for i, chunk in enumerate(
200+
queue_chunks[:2]
201+
): # Limit to 2 fields to stay within embed limits
202+
queue_str = ""
203+
for j, source in enumerate(chunk):
204+
# Truncate title to avoid exceeding field limits
205+
title = (
206+
source.title[:100] + "..."
207+
if len(source.title) > 100
208+
else source.title
209+
)
210+
entry = (
211+
f"{i * max_tracks_per_field + j + 1}. {title} ({source.url})\n"
212+
)
213+
if len(queue_str) + len(entry) > max_field_length:
214+
break
215+
queue_str += entry
216+
217+
if queue_str:
218+
embed.add_field(
219+
name=f"Up Next (Part {i + 1})" if i > 0 else "Up Next",
220+
value=queue_str,
221+
inline=False,
222+
)
223+
224+
if len(player.queue) > max_tracks_per_field * 2:
225+
embed.set_footer(
226+
text=f"And {len(player.queue) - max_tracks_per_field * 2} more tracks..."
227+
)
228+
229+
await interaction.response.send_message(embed=embed)
230+
231+
# ----------------------------------------------------------------------------------------------------
232+
233+
def get_audio_player(self, guild: Guild | None) -> DiscordAudioPlayer | None:
234+
if not guild:
235+
return None
236+
if guild.id not in self.audio_players:
237+
self.audio_players[guild.id] = DiscordAudioPlayer(guild)
238+
return self.audio_players[guild.id]
239+
240+
async def ensure_voice(self, interaction: Interaction) -> VoiceClient | None:
241+
if not interaction.user.voice:
242+
await interaction.followup.send(
243+
"You need to be in a voice channel!", ephemeral=True
244+
)
245+
return None
246+
247+
voice_channel = interaction.user.voice.channel
248+
voice_client = utils.get(self.bot.voice_clients, guild=interaction.guild)
249+
250+
if voice_client and voice_client.is_connected():
251+
if voice_client.channel != voice_channel:
252+
await voice_client.move_to(voice_channel)
253+
else:
254+
voice_client = await voice_channel.connect()
255+
256+
return voice_client

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"pynacl>=1.5.0",
2828
"python-dotenv>=1.0.1",
2929
"tabulate[widechars]>=0.9.0",
30+
"yt-dlp>=2025.3.31",
3031
]
3132
#----------------------------------------------------------------------------------------------------
3233
[dependency-groups]

utils/audio.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import asyncio
2+
import functools
3+
from typing import Any
4+
5+
import yt_dlp
6+
7+
from utils.log import logger
8+
9+
log = logger(__name__)
10+
11+
12+
# ----------------------------------------------------------------------------------------------------
13+
# * Media Source
14+
# ----------------------------------------------------------------------------------------------------
15+
class MediaSource:
16+
"""Base class for media sources from various platforms."""
17+
18+
def __init__(self, title: str, url: str, duration: int | None = None):
19+
self.title: str = title
20+
self.url: str = url
21+
self.duration: int | None = duration
22+
23+
@classmethod
24+
async def from_url(cls, url: str, *, loop=None) -> list["MediaSource"]:
25+
"""Extract audio sources from a URL."""
26+
raise NotImplementedError
27+
28+
29+
# ----------------------------------------------------------------------------------------------------
30+
# * YouTube Source
31+
# ----------------------------------------------------------------------------------------------------
32+
class YouTubeSource(MediaSource):
33+
"""Audio source for YouTube videos and playlists."""
34+
35+
YTDL = yt_dlp.YoutubeDL(
36+
{
37+
"format": "bestaudio[acodec=opus]/bestaudio[acodec=aac]/bestaudio/best", # Prefer opus or aac for Discord
38+
"noplaylist": False, # Allow playlists
39+
"quiet": True, # Suppress console output
40+
"default_search": "auto", # Enable search queries
41+
"extract_flat": True, # Faster playlist processing
42+
"retries": 3, # Retry failed requests
43+
"fragment_retries": 3, # Retry failed fragments
44+
"http_chunk_size": 1048576, # 1MB chunks for streaming
45+
"ignoreerrors": True, # Skip invalid playlist entries
46+
"socket_timeout": 10, # Timeout for slow connections
47+
"no_cache_dir": True, # Avoid disk I/O
48+
"outtmpl": "%(title)s.%(ext)s", # Consistent output naming (if downloading)
49+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", # Bypass some restrictions
50+
"force_generic_extractor": True, # Support non-YouTube platforms
51+
"max_downloads": 50, # Limit playlist size to avoid overload
52+
}
53+
)
54+
55+
@classmethod
56+
async def from_url(cls, url: str, *, loop=None) -> list["MediaSource"]:
57+
loop = loop or asyncio.get_event_loop()
58+
try:
59+
# Wrap ytdl.extract_info in executor to prevent blocking
60+
data: dict[str, Any] | None = await loop.run_in_executor(
61+
None, functools.partial(cls.YTDL.extract_info, url, download=False)
62+
)
63+
64+
# Explicitly check for None
65+
if data is None:
66+
log.error(f"No data returned for URL: {url}")
67+
return []
68+
69+
sources = []
70+
if "entries" in data:
71+
# Playlist
72+
for entry in data["entries"]:
73+
if entry:
74+
sources.append(
75+
cls(
76+
title=entry.get("title", "Unknown"),
77+
url=entry.get("url", entry.get("webpage_url", "")),
78+
duration=entry.get("duration"),
79+
)
80+
)
81+
else:
82+
# Single video
83+
sources.append(
84+
cls(
85+
title=data.get("title", "Unknown"),
86+
url=data.get("url", data.get("webpage_url", "")),
87+
duration=data.get("duration"),
88+
)
89+
)
90+
91+
return sources
92+
except Exception as e:
93+
log.error(f"Error extracting YouTube source: {e}")
94+
return []

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)