aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/audio_playback.py
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/music/audio_playback.py
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
1 files changed, 4 insertions, 288 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 )