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