diff options
Diffstat (limited to 'cogs/voice.py')
| -rw-r--r-- | cogs/voice.py | 407 |
1 files changed, 254 insertions, 153 deletions
diff --git a/cogs/voice.py b/cogs/voice.py index 6f9a269..ba1993e 100644 --- a/cogs/voice.py +++ b/cogs/voice.py | |||
| @@ -4,12 +4,15 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import discord | 6 | import discord |
| 7 | from discord.ext import commands | ||
| 8 | import logging | 7 | import logging |
| 9 | import asyncio | 8 | import asyncio |
| 9 | import os | ||
| 10 | import urllib.parse | ||
| 10 | from dataclasses import dataclass, field | 11 | from dataclasses import dataclass, field |
| 11 | from typing import Any | 12 | from typing import Any |
| 12 | 13 | ||
| 14 | import mutagen | ||
| 15 | |||
| 13 | logger = logging.getLogger(__name__) | 16 | logger = logging.getLogger(__name__) |
| 14 | 17 | ||
| 15 | 18 | ||
| @@ -25,11 +28,7 @@ def get_voice_manager(bot) -> "VoiceManager": | |||
| 25 | 28 | ||
| 26 | 29 | ||
| 27 | def is_voice_client_alive(voice_client, guild): | 30 | def is_voice_client_alive(voice_client, guild): |
| 28 | """Check if a voice client connection is healthy. | 31 | """Check if a voice client connection is healthy.""" |
| 29 | |||
| 30 | Verifies the client is connected, the channel still exists, | ||
| 31 | and the underlying voice socket is still open. | ||
| 32 | """ | ||
| 33 | if voice_client is None: | 32 | if voice_client is None: |
| 34 | return False | 33 | return False |
| 35 | if not voice_client.is_connected(): | 34 | if not voice_client.is_connected(): |
| @@ -52,66 +51,75 @@ def is_voice_client_alive(voice_client, guild): | |||
| 52 | 51 | ||
| 53 | @dataclass | 52 | @dataclass |
| 54 | class VoiceManager: | 53 | class VoiceManager: |
| 55 | bot: Any # type: ignore[no-any-import] | 54 | bot: Any |
| 56 | _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) | 55 | _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) |
| 57 | _connecting: set[int] = field(default_factory=set, init=False) | 56 | _connecting: set[int] = field(default_factory=set, init=False) |
| 58 | 57 | ||
| 59 | async def join_channel(self, channel): | 58 | # --- Connection ----------------------------------------------------------- |
| 60 | """Join a voice channel by channel object.""" | 59 | |
| 61 | async with self._voice_lock: | 60 | async def _ensure_voice_client(self, channel): |
| 62 | guild = channel.guild | 61 | """Unified connect/move/reconnect logic. |
| 63 | guild_id = guild.id | 62 | |
| 63 | Returns a healthy VoiceClient connected to *channel*, or None on failure. | ||
| 64 | """ | ||
| 65 | guild = channel.guild | ||
| 66 | guild_id = guild.id | ||
| 64 | 67 | ||
| 68 | async with self._lock: | ||
| 65 | if guild_id in self._connecting: | 69 | if guild_id in self._connecting: |
| 66 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") | 70 | logger.info(f"Connection already in progress for guild {guild_id}, skipping") |
| 67 | return None | 71 | return None |
| 68 | 72 | ||
| 69 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | 73 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) |
| 70 | 74 | ||
| 71 | if voice_client and voice_client.is_connected(): | 75 | # --- resolve existing client -------------------------------------- |
| 72 | if voice_client.channel == channel: | 76 | if vc is not None: |
| 73 | if is_voice_client_alive(voice_client, guild): | 77 | if vc.is_connected(): |
| 74 | logger.info(f"Already connected to {channel}") | 78 | if vc.channel == channel: |
| 75 | return voice_client | 79 | if is_voice_client_alive(vc, guild): |
| 76 | logger.warning(f"Stale connection to {channel}, reconnecting") | 80 | logger.info(f"Already connected to {channel}") |
| 77 | try: | 81 | return vc |
| 78 | await voice_client.disconnect() | 82 | logger.warning(f"Stale connection to {channel}, reconnecting") |
| 79 | except Exception: | ||
| 80 | pass | ||
| 81 | else: | ||
| 82 | try: | ||
| 83 | await voice_client.move_to(channel) | ||
| 84 | logger.info(f"Moved to voice channel: {channel}") | ||
| 85 | return voice_client | ||
| 86 | except Exception as e: | ||
| 87 | logger.error(f"Failed to move voice client: {e}") | ||
| 88 | logger.info("Disconnecting after failed move attempt") | ||
| 89 | try: | 83 | try: |
| 90 | await voice_client.disconnect() | 84 | await vc.disconnect() |
| 91 | except Exception: | 85 | except Exception: |
| 92 | pass | 86 | pass |
| 93 | elif voice_client and not voice_client.is_connected(): | 87 | vc = None |
| 94 | logger.info("Voice client exists but disconnected, cleaning up stale entry") | 88 | else: |
| 95 | try: | 89 | try: |
| 96 | await voice_client.disconnect() | 90 | await vc.move_to(channel) |
| 97 | except Exception: | 91 | logger.info(f"Moved to voice channel: {channel}") |
| 98 | pass | 92 | return vc |
| 99 | voice_client = None | 93 | except Exception as e: |
| 94 | logger.error(f"Failed to move voice client: {e}") | ||
| 95 | try: | ||
| 96 | await vc.disconnect() | ||
| 97 | except Exception: | ||
| 98 | pass | ||
| 99 | vc = None | ||
| 100 | else: | ||
| 101 | logger.info("Voice client exists but disconnected, cleaning up stale entry") | ||
| 102 | try: | ||
| 103 | await vc.disconnect() | ||
| 104 | except Exception: | ||
| 105 | pass | ||
| 106 | vc = None | ||
| 100 | 107 | ||
| 108 | # --- create fresh connection -------------------------------------- | ||
| 101 | self._connecting.add(guild_id) | 109 | self._connecting.add(guild_id) |
| 102 | try: | 110 | try: |
| 103 | await asyncio.sleep(0.3) | 111 | await asyncio.sleep(0.3) |
| 104 | try: | 112 | try: |
| 105 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 113 | vc = await channel.connect(timeout=30.0, reconnect=False) |
| 106 | logger.info(f"Connected to voice channel: {channel}") | 114 | logger.info(f"Connected to voice channel: {channel}") |
| 107 | return voice_client | 115 | return vc |
| 108 | except discord.ClientException as e: | 116 | except discord.ClientException as e: |
| 109 | if "Already connected" in str(e): | 117 | if "Already connected" in str(e): |
| 110 | logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") | 118 | logger.warning(f"Stale voice state detected, forcing reconnect: {e}") |
| 111 | voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) | 119 | vc = discord.utils.get(self.bot.voice_clients, guild=guild) |
| 112 | if voice_client: | 120 | if vc: |
| 113 | try: | 121 | try: |
| 114 | await voice_client.disconnect() | 122 | await vc.disconnect() |
| 115 | except Exception: | 123 | except Exception: |
| 116 | pass | 124 | pass |
| 117 | else: | 125 | else: |
| @@ -121,9 +129,9 @@ class VoiceManager: | |||
| 121 | pass | 129 | pass |
| 122 | await asyncio.sleep(0.3) | 130 | await asyncio.sleep(0.3) |
| 123 | try: | 131 | try: |
| 124 | voice_client = await channel.connect(timeout=30.0, reconnect=False) | 132 | vc = await channel.connect(timeout=30.0, reconnect=False) |
| 125 | logger.info(f"Reconnected to voice channel: {channel}") | 133 | logger.info(f"Reconnected to voice channel: {channel}") |
| 126 | return voice_client | 134 | return vc |
| 127 | except Exception as e2: | 135 | except Exception as e2: |
| 128 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") | 136 | logger.error(f"Failed to reconnect after stale cleanup: {e2}") |
| 129 | return None | 137 | return None |
| @@ -135,144 +143,237 @@ class VoiceManager: | |||
| 135 | finally: | 143 | finally: |
| 136 | self._connecting.discard(guild_id) | 144 | self._connecting.discard(guild_id) |
| 137 | 145 | ||
| 138 | async def join_by_channel_id(self, channel_id): | 146 | async def connect(self, channel): |
| 139 | """Join a voice channel by channel ID.""" | 147 | """Join a voice channel (public alias).""" |
| 140 | channel = self.bot.get_channel(channel_id) | 148 | return await self._ensure_voice_client(channel) |
| 141 | if not channel: | ||
| 142 | logger.error(f"Channel {channel_id} not found") | ||
| 143 | return None | ||
| 144 | if not isinstance(channel, discord.VoiceChannel): | ||
| 145 | logger.error(f"Channel {channel_id} is not a voice channel") | ||
| 146 | return None | ||
| 147 | return await self.join_channel(channel) | ||
| 148 | 149 | ||
| 149 | async def get_voice_client(self, guild): | 150 | async def join_channel(self, channel): |
| 151 | """Backward-compatible alias for connect().""" | ||
| 152 | return await self.connect(channel) | ||
| 153 | |||
| 154 | async def get_client(self, guild): | ||
| 150 | """Get existing voice client for a guild.""" | 155 | """Get existing voice client for a guild.""" |
| 151 | return discord.utils.get(self.bot.voice_clients, guild=guild) | 156 | return discord.utils.get(self.bot.voice_clients, guild=guild) |
| 152 | 157 | ||
| 153 | async def get_voice_status(self, guild): | 158 | async def get_voice_client(self, guild): |
| 154 | """Get voice client status including connection and playback state. | 159 | """Backward-compatible alias for get_client().""" |
| 160 | return await self.get_client(guild) | ||
| 161 | |||
| 162 | async def get_status(self, guild): | ||
| 163 | """Get voice client status. | ||
| 155 | 164 | ||
| 156 | Returns: | 165 | Returns: |
| 157 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client | 166 | tuple: (voice_client, is_connected, is_playing) or (None, False, False) |
| 158 | """ | 167 | """ |
| 159 | voice_client = await self.get_voice_client(guild) | 168 | vc = await self.get_client(guild) |
| 160 | if voice_client is None: | 169 | if vc is None: |
| 161 | return None, False, False | 170 | return None, False, False |
| 171 | return vc, vc.is_connected(), vc.is_playing() | ||
| 162 | 172 | ||
| 163 | is_connected = voice_client.is_connected() | 173 | async def get_voice_status(self, guild): |
| 164 | is_playing = voice_client.is_playing() if is_connected else False | 174 | """Backward-compatible alias for get_status().""" |
| 175 | return await self.get_status(guild) | ||
| 176 | |||
| 177 | async def disconnect(self, guild): | ||
| 178 | """Stop playback and disconnect from voice.""" | ||
| 179 | vc = await self.get_client(guild) | ||
| 180 | if vc: | ||
| 181 | vc.stop() | ||
| 182 | logger.info("Disconnecting from voice") | ||
| 183 | await vc.disconnect() | ||
| 184 | logger.info(f"Disconnected from guild {guild}") | ||
| 165 | 185 | ||
| 166 | return voice_client, is_connected, is_playing | 186 | async def stop(self, guild): |
| 187 | """Backward-compatible alias for disconnect().""" | ||
| 188 | await self.disconnect(guild) | ||
| 167 | 189 | ||
| 168 | async def play_audio(self, channel, audio_source, volume=1.0): | 190 | # --- Playback ------------------------------------------------------------ |
| 169 | """Play audio in a voice channel.""" | ||
| 170 | voice_client = await self.join_channel(channel) | ||
| 171 | if not voice_client: | ||
| 172 | return False | ||
| 173 | 191 | ||
| 174 | if abs(volume - 1.0) > 1e-9: | 192 | async def _play_await(self, vc, audio_source): |
| 175 | audio_source = discord.PCMVolumeTransformer(audio_source) | 193 | """Play an audio source and wait for completion using an asyncio.Future. |
| 176 | audio_source.volume = volume | 194 | |
| 195 | Returns True on success, False on error. No polling, no magic sleeps. | ||
| 196 | """ | ||
| 197 | loop = asyncio.get_event_loop() | ||
| 198 | future = loop.create_future() | ||
| 199 | |||
| 200 | def after(error): | ||
| 201 | if not future.done(): | ||
| 202 | if error: | ||
| 203 | future.set_exception(Exception(str(error))) | ||
| 204 | else: | ||
| 205 | future.set_result(True) | ||
| 206 | |||
| 207 | vc.play(audio_source, after=after) | ||
| 177 | 208 | ||
| 178 | try: | 209 | try: |
| 179 | voice_client.play(audio_source) | 210 | await future |
| 180 | while voice_client.is_playing(): | ||
| 181 | await asyncio.sleep(0.1) | ||
| 182 | return True | 211 | return True |
| 183 | except Exception as e: | 212 | except Exception as e: |
| 184 | logger.error(f"Error playing audio: {e}") | 213 | logger.error(f"Playback error: {e}") |
| 185 | return False | 214 | return False |
| 186 | 215 | ||
| 187 | async def stop(self, guild): | 216 | async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): |
| 188 | """Stop playing and disconnect from voice.""" | 217 | """Connect to *channel* and play a pre-made audio source. |
| 189 | voice_client = await self.get_voice_client(guild) | 218 | |
| 190 | if voice_client: | 219 | Returns True on success, False on failure. |
| 191 | voice_client.stop() | 220 | """ |
| 192 | logger.info("stop() called - disconnecting from voice") | 221 | vc = await self.connect(channel) |
| 193 | await voice_client.disconnect() | 222 | if not vc: |
| 194 | logger.info(f"Stopped and disconnected from guild {guild}") | 223 | logger.error("play_source: could not connect to voice channel") |
| 224 | return False | ||
| 195 | 225 | ||
| 226 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 227 | vc.stop() | ||
| 196 | 228 | ||
| 197 | class Voice(commands.Cog): | 229 | if abs(volume - 1.0) > 1e-9: |
| 198 | def __init__(self, bot): | 230 | audio_source = discord.PCMVolumeTransformer(audio_source) |
| 199 | self.bot = bot | 231 | audio_source.volume = volume |
| 200 | self._voice_lock = asyncio.Lock() | ||
| 201 | 232 | ||
| 202 | async def _validate_user_voice_state(self, ctx): | 233 | logger.info("Playing via play_source") |
| 203 | if not ctx.author.voice or not ctx.author.voice.channel: | 234 | return await self._play_await(vc, audio_source) |
| 204 | await ctx.send("You need to be in a voice channel first!") | ||
| 205 | return False | ||
| 206 | return True | ||
| 207 | 235 | ||
| 208 | async def _create_voice_client(self, ctx): | 236 | # --- Source preparation (folded from AudioPlaybackManager) --------------- |
| 209 | async with self._voice_lock: | ||
| 210 | try: | ||
| 211 | await asyncio.sleep(0.5) | ||
| 212 | voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False) | ||
| 213 | logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") | ||
| 214 | return voice_client | ||
| 215 | except asyncio.TimeoutError: | ||
| 216 | await ctx.send("Connection to voice channel timed out.") | ||
| 217 | return None | ||
| 218 | except discord.ClientException as e: | ||
| 219 | logger.error(f"Failed to connect to voice channel: {e}") | ||
| 220 | await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) | ||
| 221 | return None | ||
| 222 | 237 | ||
| 223 | async def _move_voice_client(self, voice_client, ctx): | 238 | @staticmethod |
| 224 | try: | 239 | async def validate_voice_channel(ctx): |
| 225 | await voice_client.move_to(ctx.author.voice.channel) | 240 | """Validate user is in a voice channel. Returns the channel or None.""" |
| 226 | logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") | 241 | if not ctx.author.voice or not ctx.author.voice.channel: |
| 227 | return voice_client | 242 | await ctx.respond( |
| 228 | except asyncio.TimeoutError: | 243 | "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True |
| 229 | await ctx.send("Connection to voice channel timed out.") | 244 | ) |
| 230 | return None | ||
| 231 | except discord.ClientException as e: | ||
| 232 | logger.error(f"Failed to move to voice channel: {e}") | ||
| 233 | await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True) | ||
| 234 | return None | 245 | return None |
| 246 | return ctx.author.voice.channel | ||
| 247 | |||
| 248 | @staticmethod | ||
| 249 | async def prepare_file_source(source): | ||
| 250 | """Validate a local file and return (display_name, absolute_path) or (None, None).""" | ||
| 251 | logger.info(f"Attempting to play local file: {source}") | ||
| 252 | |||
| 253 | if not os.path.isabs(source): | ||
| 254 | source = os.path.abspath(source) | ||
| 255 | logger.debug(f"Converted to absolute path: {source}") | ||
| 256 | |||
| 257 | if not os.path.exists(source): | ||
| 258 | logger.error(f"File not found: {source}") | ||
| 259 | return None, None | ||
| 260 | |||
| 261 | filename = os.path.basename(source) | ||
| 262 | display_name = os.path.splitext(filename)[0] | ||
| 263 | return display_name, source | ||
| 235 | 264 | ||
| 236 | async def _verify_connection(self, voice_client): | 265 | @staticmethod |
| 237 | if not voice_client.is_connected(): | 266 | async def prepare_audio_source(source, is_url, display_name): |
| 267 | """Prepare FFmpegPCMAudio from a URL or local file. | ||
| 268 | |||
| 269 | Returns (audio_source, final_display_name). | ||
| 270 | """ | ||
| 271 | if is_url: | ||
| 272 | before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" | ||
| 273 | audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) | ||
| 274 | filename = os.path.basename(source.split("?")[0]) | ||
| 275 | filename = urllib.parse.unquote(filename) | ||
| 276 | clean_filename = os.path.splitext(filename)[0] | ||
| 277 | return audio_source, clean_filename | ||
| 278 | else: | ||
| 279 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 238 | try: | 280 | try: |
| 239 | await voice_client.connect(reconnect=False, timeout=10.0) | 281 | metadata = mutagen.open(source) |
| 240 | return voice_client | 282 | if metadata: |
| 283 | title = ( | ||
| 284 | metadata.get("title", [None])[0] | ||
| 285 | if hasattr(metadata, "get") | ||
| 286 | else None | ||
| 287 | ) | ||
| 288 | if title: | ||
| 289 | display_name = title | ||
| 290 | logger.debug(f"Read metadata - title: {title}") | ||
| 291 | else: | ||
| 292 | display_name = os.path.splitext(os.path.basename(source))[0] | ||
| 241 | except Exception as e: | 293 | except Exception as e: |
| 242 | logger.error(f"Failed to reconnect to voice: {e}") | 294 | logger.warning(f"Could not read metadata from {source}: {str(e)}") |
| 243 | return None | 295 | display_name = os.path.splitext(os.path.basename(source))[0] |
| 244 | return voice_client | ||
| 245 | 296 | ||
| 246 | async def ensure_voice_client(self, ctx): | 297 | return audio_source, display_name |
| 247 | if not await self._validate_user_voice_state(ctx): | ||
| 248 | return None | ||
| 249 | 298 | ||
| 250 | voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) | 299 | @staticmethod |
| 300 | async def prepare_source(source): | ||
| 301 | """Determine whether *source* is a URL or local file. | ||
| 251 | 302 | ||
| 252 | if voice_client and not voice_client.is_connected(): | 303 | Returns (source_path, display_name, is_url) or (None, None, None) on failure. |
| 304 | """ | ||
| 305 | is_url = source.startswith(("http://", "https://")) | ||
| 306 | |||
| 307 | if is_url: | ||
| 308 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 309 | return source, source, is_url | ||
| 310 | |||
| 311 | display_name, absolute_source = await VoiceManager.prepare_file_source(source) | ||
| 312 | if not display_name: | ||
| 313 | return None, None, None | ||
| 314 | |||
| 315 | return absolute_source, display_name, is_url | ||
| 316 | |||
| 317 | @staticmethod | ||
| 318 | async def cleanup_temp_file(source): | ||
| 319 | """Remove a temporary file if it exists.""" | ||
| 320 | if source and os.path.exists(source): | ||
| 253 | try: | 321 | try: |
| 254 | logger.info("Voice client exists but not connected - disconnecting stale client") | 322 | os.remove(source) |
| 255 | await voice_client.disconnect() | 323 | logger.info(f"Cleaned up temporary file: {source}") |
| 256 | except Exception: | 324 | except Exception as e: |
| 257 | pass | 325 | logger.error(f"Error cleaning up file {source}: {str(e)}") |
| 258 | voice_client = None | ||
| 259 | |||
| 260 | if not voice_client or not voice_client.is_connected(): | ||
| 261 | if not await self._validate_user_voice_state(ctx): | ||
| 262 | return None | ||
| 263 | return await self._create_voice_client(ctx) | ||
| 264 | 326 | ||
| 265 | if voice_client.channel != ctx.author.voice.channel: | 327 | @staticmethod |
| 266 | return await self._move_voice_client(voice_client, ctx) | 328 | async def cleanup_if_needed(source, delete_after_play, is_url): |
| 329 | """Conditionally clean up a file after playback.""" | ||
| 330 | if delete_after_play and not is_url: | ||
| 331 | await VoiceManager.cleanup_temp_file(source) | ||
| 267 | 332 | ||
| 268 | return await self._verify_connection(voice_client) | 333 | # --- High-level play (full flow) ----------------------------------------- |
| 269 | 334 | ||
| 270 | async def connect(self, ctx): | 335 | async def play(self, ctx, source, delete_after_play=False, interrupt=True): |
| 271 | voice_client = await self.ensure_voice_client(ctx) | 336 | """High-level playback: validate context, prepare source, connect, play, cleanup. |
| 272 | if not voice_client: | 337 | |
| 273 | return None | 338 | Args: |
| 274 | return voice_client | 339 | ctx: Discord application context (used for channel resolution & error messages). |
| 340 | source: Local file path or URL to stream. | ||
| 341 | delete_after_play: Remove local file after playback (no-op for URLs). | ||
| 342 | interrupt: Stop currently playing audio before starting. | ||
| 343 | |||
| 344 | Returns: | ||
| 345 | bool: True if playback completed successfully, False otherwise. | ||
| 346 | """ | ||
| 347 | prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) | ||
| 348 | if not prepared_source: | ||
| 349 | await VoiceManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 350 | return False | ||
| 351 | |||
| 352 | target_channel = await VoiceManager.validate_voice_channel(ctx) | ||
| 353 | if not target_channel: | ||
| 354 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 355 | return False | ||
| 356 | |||
| 357 | vc = await self.connect(target_channel) | ||
| 358 | if not vc: | ||
| 359 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 360 | return False | ||
| 361 | |||
| 362 | if interrupt and (vc.is_playing() or vc.is_paused()): | ||
| 363 | vc.stop() | ||
| 364 | |||
| 365 | audio_source, final_display_name = await VoiceManager.prepare_audio_source( | ||
| 366 | prepared_source, is_url, display_name | ||
| 367 | ) | ||
| 368 | |||
| 369 | success = await self._play_await(vc, audio_source) | ||
| 370 | |||
| 371 | if success: | ||
| 372 | logger.info(f"Playback completed: {final_display_name}") | ||
| 373 | else: | ||
| 374 | logger.error(f"Playback failed: {prepared_source}") | ||
| 275 | 375 | ||
| 376 | if not success: | ||
| 377 | await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
| 276 | 378 | ||
| 277 | def setup(bot): | 379 | return success |
| 278 | bot.add_cog(Voice(bot)) | ||
