aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/voice.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--cogs/voice.py504
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
6import discord 6import discord
7from discord.ext import commands
8import logging 7import logging
9import asyncio 8import asyncio
9import os
10import urllib.parse
10from dataclasses import dataclass, field 11from dataclasses import dataclass, field
11from typing import Any 12from typing import Any
12 13
14import mutagen
15
13logger = logging.getLogger(__name__) 16logger = 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
30def 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
28class VoiceManager: 53class 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
141class 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
221def setup(bot): 444 return success
222 bot.add_cog(Voice(bot))