aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/voice.py
diff options
context:
space:
mode:
Diffstat (limited to 'cogs/voice.py')
-rw-r--r--cogs/voice.py407
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
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
@@ -25,11 +28,7 @@ def get_voice_manager(bot) -> "VoiceManager":
25 28
26 29
27def is_voice_client_alive(voice_client, guild): 30def 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
54class VoiceManager: 53class 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
197class 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
277def setup(bot): 379 return success
278 bot.add_cog(Voice(bot))