aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/voice.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--cogs/voice.py444
1 files changed, 0 insertions, 444 deletions
diff --git a/cogs/voice.py b/cogs/voice.py
deleted file mode 100644
index a224336..0000000
--- a/cogs/voice.py
+++ /dev/null
@@ -1,444 +0,0 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6import discord
7import logging
8import asyncio
9import os
10import urllib.parse
11from dataclasses import dataclass, field
12from typing import Any
13
14import mutagen
15
16logger = logging.getLogger(__name__)
17
18
19_voice_managers: dict[int, "VoiceManager"] = {}
20
21
22def get_voice_manager(bot) -> "VoiceManager":
23 """Get or create VoiceManager for a specific bot instance."""
24 bot_id = bot.user.id if bot.user else id(bot)
25 if bot_id not in _voice_managers:
26 _voice_managers[bot_id] = VoiceManager(bot)
27 return _voice_managers[bot_id]
28
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
52@dataclass
53class VoiceManager:
54 bot: Any
55 _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
56 _connecting: set[int] = field(default_factory=set, init=False)
57
58 # --- Connection -----------------------------------------------------------
59
60 async def _ensure_voice_client(self, channel):
61 """Unified connect/move/reconnect logic.
62
63 Returns a healthy VoiceClient connected to *channel*, or None on failure.
64 """
65 guild = channel.guild
66 guild_id = guild.id
67
68 async with self._lock:
69 if guild_id in self._connecting:
70 logger.info(f"Connection already in progress for guild {guild_id}, skipping")
71 return None
72
73 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
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")
132 try:
133 await vc.disconnect()
134 except Exception:
135 pass
136 try:
137 await vc.connect(
138 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
139 )
140 logger.info(f"Reconnected disconnected client: {channel}")
141 return vc
142 except Exception as e:
143 logger.error(f"Failed to reconnect disconnected client: {e}")
144 try:
145 channel._state._remove_voice_client(guild.id)
146 except Exception:
147 pass
148 vc = None
149
150 # --- create fresh connection --------------------------------------
151 self._connecting.add(guild_id)
152 try:
153 await asyncio.sleep(0.3)
154 try:
155 vc = await channel.connect(timeout=10.0, reconnect=False)
156 logger.info(f"Connected to voice channel: {channel}")
157 return vc
158 except discord.ClientException as e:
159 if "Already connected" in str(e):
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)
195
196 async def join_by_channel_id(self, channel_id):
197 """Join a voice channel by channel ID."""
198 channel = self.bot.get_channel(channel_id)
199 if not channel:
200 logger.error(f"Channel {channel_id} not found")
201 return None
202 if not isinstance(channel, discord.VoiceChannel):
203 logger.error(f"Channel {channel_id} is not a voice channel")
204 return None
205 return await self.connect(channel)
206
207 async def get_client(self, guild):
208 """Get existing voice client for a guild."""
209 return discord.utils.get(self.bot.voice_clients, guild=guild)
210
211 async def get_voice_client(self, guild):
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.
217
218 Returns:
219 tuple: (voice_client, is_connected, is_playing) or (None, False, False)
220 """
221 vc = await self.get_client(guild)
222 if vc is None:
223 return None, False, False
224 return vc, vc.is_connected(), vc.is_playing()
225
226 async def get_voice_status(self, guild):
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}")
238
239 async def stop(self, guild):
240 """Backward-compatible alias for disconnect()."""
241 await self.disconnect(guild)
242
243 # --- Playback ------------------------------------------------------------
244
245 async def _play_await(self, vc, audio_source):
246 """Play an audio source and wait for completion using an asyncio.Future.
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)
261
262 try:
263 await future
264 return True
265 except Exception as e:
266 logger.error(f"Playback error: {e}")
267 return False
268
269 async def play_source(self, channel, audio_source, interrupt=True, volume=1.0):
270 """Connect to *channel* and play a pre-made audio source.
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
278
279 if interrupt and (vc.is_playing() or vc.is_paused()):
280 vc.stop()
281
282 if abs(volume - 1.0) > 1e-9:
283 audio_source = discord.PCMVolumeTransformer(audio_source)
284 audio_source.volume = volume
285
286 logger.info("Playing via play_source")
287 return await self._play_await(vc, audio_source)
288
289 # --- Source preparation (folded from AudioPlaybackManager) ---------------
290
291 @staticmethod
292 async def validate_voice_channel(ctx):
293 """Validate user is in a voice channel. Returns the channel or None."""
294 if not ctx.author.voice or not ctx.author.voice.channel:
295 await ctx.respond(
296 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
297 )
298 return None
299 return ctx.author.voice.channel
300
301 @staticmethod
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)
333 try:
334 metadata = mutagen.open(source)
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]
346 except Exception as e:
347 logger.warning(f"Could not read metadata from {source}: {str(e)}")
348 display_name = os.path.splitext(os.path.basename(source))[0]
349
350 return audio_source, display_name
351
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
363
364 display_name, absolute_source = await VoiceManager.prepare_file_source(source)
365 if not display_name:
366 return None, None, None
367
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):
374 try:
375 os.remove(source)
376 logger.info(f"Cleaned up temporary file: {source}")
377 except Exception as e:
378 logger.error(f"Error cleaning up file {source}: {str(e)}")
379
380 @staticmethod
381 async def cleanup_if_needed(source, delete_after_play, is_url):
382 """Conditionally clean up a file after playback."""
383 if delete_after_play and not is_url:
384 await VoiceManager.cleanup_temp_file(source)
385
386 # --- High-level play (full flow) -----------------------------------------
387
388 async def play(self, ctx, source, delete_after_play=False, interrupt=True):
389 """High-level playback: validate context, prepare source, connect, play, cleanup.
390
391 Sends ``grom: {name}`` immediately on start, and an error follow-up
392 if playback fails.
393
394 Args:
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)
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)
443
444 return success