aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-20 15:36:17 +0200
committersalmonik <raisersoft@proton.me>2026-05-20 15:36:17 +0200
commitfbdfad855ab9a1233e286806439397729ba21226 (patch)
tree765cbedeacab1c703bc218a3f83bf1332fc798ab /cogs
parenthealth checks for vc (diff)
downloadsenator-sperma-fbdfad855ab9a1233e286806439397729ba21226.tar.gz
senator-sperma-fbdfad855ab9a1233e286806439397729ba21226.zip
Fold AudioPlaybackManager into VoiceManager + Future-based play
- Move all static helpers (validate_voice_channel, prepare_source, prepare_audio_source, etc.) from AudioPlaybackManager into VoiceManager - Add _play_await() using asyncio.Future + after callback, replacing all polling patterns and the 5-second sleep hack - Add play() high-level method and play_source() for pre-made sources - Unify connection logic under single _ensure_voice_client() - Remove dead Voice cog class and its setup() - Gut audio_playback.py to a re-export shim - Remove cogs.voice from main.py extensions
Diffstat (limited to '')
-rw-r--r--cogs/music/audio_playback.py292
-rw-r--r--cogs/voice.py407
2 files changed, 258 insertions, 441 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
index 0c885c3..5206a96 100644
--- a/cogs/music/audio_playback.py
+++ b/cogs/music/audio_playback.py
@@ -3,292 +3,8 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import asyncio 6# Re-export shim — all voice utilities now live in cogs.voice.VoiceManager.
7import logging 7# Kept so existing imports in play_commands.py and ai.py keep working.
8import os 8# TODO: update importers and remove this file.
9import urllib.parse
10 9
11import discord 10from cogs.voice import VoiceManager as AudioPlaybackManager # noqa: F401
12import mutagen
13
14logger = logging.getLogger(__name__)
15
16
17class AudioPlaybackManager:
18 """Handles audio playback, voice connections, and source preparation."""
19
20 @staticmethod
21 async def cleanup_temp_file(source):
22 """Clean up temporary file if it exists."""
23 if source and os.path.exists(source):
24 try:
25 os.remove(source)
26 logger.info(f"Cleaned up temporary file: {source}")
27 except Exception as e:
28 logger.error(f"Error cleaning up file {source}: {str(e)}")
29
30 @staticmethod
31 async def validate_voice_channel(ctx):
32 """Validate user is in a voice channel. Returns channel or None."""
33 if not ctx.author.voice or not ctx.author.voice.channel:
34 await ctx.respond(
35 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
36 )
37 return None
38 return ctx.author.voice.channel
39
40 @staticmethod
41 async def prepare_file_source(source):
42 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None)."""
43 logger.info(f"Attempting to play local file: {source}")
44
45 # Get absolute path and verify file exists
46 if not os.path.isabs(source):
47 source = os.path.abspath(source)
48 logger.debug(f"Converted to absolute path: {source}")
49
50 if not os.path.exists(source):
51 logger.error(f"File not found: {source}")
52 return None, None
53
54 filename = os.path.basename(source)
55 display_name = os.path.splitext(filename)[0]
56 return display_name, source
57
58 @staticmethod
59 async def connect_to_voice_channel(ctx, vc, target_channel):
60 """Connect or move to voice channel. Returns voice client."""
61 try:
62 # Check if vc is valid and actually connected
63 if vc is not None and vc.is_connected():
64 # Already connected - check if need to move
65 if vc.channel != target_channel:
66 logger.info(f"Moving to voice channel: {target_channel.name}")
67 await vc.move_to(target_channel)
68 logger.info(f"Moved to voice channel: {target_channel.name}")
69 return vc
70
71 # Not connected - connect now
72 logger.info(f"Connecting to voice channel: {target_channel.name}")
73 vc = await target_channel.connect()
74 logger.info(f"Connected to voice channel: {target_channel.name}")
75 return vc
76 except Exception as e:
77 logger.error(f"Error connecting to voice channel: {str(e)}")
78 raise
79
80 @staticmethod
81 async def prepare_audio_source(source, is_url, display_name):
82 """Prepare audio source for playback. Returns (source, final_display_name)."""
83 if is_url:
84 # For URLs, use FFmpeg with the direct URL and extract clean filename
85 before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
86 audio_source = discord.FFmpegPCMAudio(source, before_options=before_options)
87 # Extract clean filename for URL (remove extension and decode URL encoding)
88 filename = os.path.basename(source.split("?")[0]) # Remove query parameters
89 filename = urllib.parse.unquote(filename) # Decode URL encoding
90 clean_filename = os.path.splitext(filename)[0] # Remove file extension
91 return audio_source, clean_filename
92 else:
93 # For local files, use default options
94 audio_source = discord.FFmpegPCMAudio(source=source)
95
96 # Try to get metadata for local files
97 try:
98 metadata = mutagen.open(source) # type: ignore[call-overload]
99 if metadata:
100 title = (
101 metadata.get("title", [None])[0]
102 if hasattr(metadata, "get")
103 else None
104 )
105 if title:
106 display_name = title
107 logger.debug(f"Read metadata - title: {title}")
108 else:
109 display_name = os.path.splitext(os.path.basename(source))[0]
110 except Exception as e:
111 logger.warning(f"Could not read metadata from {source}: {str(e)}")
112 display_name = os.path.splitext(os.path.basename(source))[0]
113
114 return audio_source, display_name
115
116 @staticmethod
117 async def cleanup_if_needed(source, delete_after_play, is_url):
118 """Clean up temporary file if needed."""
119 if delete_after_play and not is_url:
120 await AudioPlaybackManager.cleanup_temp_file(source)
121
122 @staticmethod
123 async def prepare_source(ctx, source):
124 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None)."""
125 is_url = source.startswith(("http://", "https://"))
126
127 if is_url:
128 logger.info(f"Attempting to play stream from URL: {source}")
129 return source, source, is_url
130
131 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(
132 source
133 )
134 if not display_name:
135 return None, None, None
136
137 return absolute_source, display_name, is_url
138
139 @staticmethod
140 async def execute_playback(ctx, source, display_name, is_url, interrupt=True):
141 """Execute the actual audio playback.
142
143 Args:
144 ctx: Discord context
145 source: Audio source path or URL
146 display_name: Name to display
147 is_url: Whether source is a URL
148 interrupt: If True, stop any currently playing audio. If False, skip if audio is playing.
149 """
150 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
151 if not target_channel:
152 return False
153
154 # Get existing voice client from bot or connect
155 guild = ctx.guild
156 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
157
158 try:
159 # Handle various connection states
160 if vc is None:
161 logger.info("No existing voice client, connecting...")
162 try:
163 vc = await target_channel.connect()
164 except discord.ClientException:
165 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
166 elif not vc.is_connected():
167 logger.info("Voice client exists but disconnected, reconnecting...")
168 try:
169 await vc.disconnect()
170 except Exception:
171 pass
172 try:
173 vc = await target_channel.connect()
174 except discord.ClientException:
175 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
176 elif vc.channel == target_channel:
177 from cogs.voice import is_voice_client_alive
178 if not is_voice_client_alive(vc, guild):
179 logger.warning("Stale voice client, reconnecting...")
180 try:
181 await vc.disconnect()
182 except Exception:
183 pass
184 try:
185 vc = await target_channel.connect()
186 except discord.ClientException:
187 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
188 elif vc.channel != target_channel:
189 logger.info(f"Moving to voice channel: {target_channel.name}")
190 await vc.move_to(target_channel)
191
192 # Stop any currently playing audio (if interrupt is True)
193 if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr]
194 vc.stop() # type: ignore[union-attr]
195 elif not interrupt and vc.is_playing(): # type: ignore[union-attr]
196 logger.info("Audio already playing and interrupt=False, skipping playback")
197 return False
198
199 # Prepare audio source
200 (
201 audio_source,
202 final_display_name,
203 ) = await AudioPlaybackManager.prepare_audio_source(
204 source, is_url, display_name
205 )
206
207 # Track playback state
208 playback_state = {"error": None, "finished": False}
209
210 def after_playing(error):
211 if error:
212 playback_state["error"] = str(error)
213 logger.error(f"Playback error: {error}")
214 playback_state["finished"] = True
215
216 # Play the audio
217 vc.play(audio_source, after=after_playing)
218 logger.info(f"Playing: {final_display_name}")
219 await ctx.followup.send(f"grom: {final_display_name}")
220
221 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
222 await asyncio.sleep(5)
223
224 # Check if playback failed to start (not just finished quickly)
225 if playback_state["error"]:
226 logger.error(f"Playback failed: {playback_state['error']}")
227 await ctx.followup.send(
228 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
229 )
230 return False
231
232 # If playback finished within 5s, it was a short file - not an error
233 if playback_state["finished"]:
234 logger.info(f"Playback completed quickly: {final_display_name}")
235 return True
236
237 if not vc.is_playing():
238 logger.error(f"Stream failed - not playing after 5s: {source}")
239 await ctx.followup.send(
240 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
241 )
242 return False
243
244 return True
245
246 except Exception as e:
247 logger.error(f"Error in play: {str(e)}", exc_info=True)
248 await ctx.followup.send(f"eror: {str(e)}", ephemeral=True)
249 return False
250
251 @staticmethod
252 async def validate_and_prepare_source(ctx, source):
253 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
254 (
255 prepared_source,
256 display_name,
257 is_url,
258 ) = await AudioPlaybackManager.prepare_source(ctx, source)
259 if not prepared_source:
260 return None, None, None
261 return prepared_source, display_name, is_url
262
263 @staticmethod
264 async def play(ctx, source, delete_after_play=False, interrupt=True):
265 """Play audio from a local file or URL.
266
267 Args:
268 ctx: Discord context
269 source: Either a local file path or a URL to stream from
270 delete_after_play: If True, delete the file after playing (only for local files)
271 interrupt: If True, stop any currently playing audio. If False, skip if audio is playing.
272 """
273 # Validate and prepare source
274 (
275 prepared_source,
276 display_name,
277 is_url,
278 ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
279 if not prepared_source:
280 await AudioPlaybackManager.cleanup_if_needed(
281 source, delete_after_play, False
282 )
283 return
284
285 # Execute playback
286 success = await AudioPlaybackManager.execute_playback(
287 ctx, prepared_source, display_name, is_url, interrupt
288 )
289
290 # Cleanup if needed
291 if not success:
292 await AudioPlaybackManager.cleanup_if_needed(
293 prepared_source, delete_after_play, is_url
294 )
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))