aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/audio_playback.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-05-21 17:10:23 +0200
committersalmonik <raisersoft@proton.me>2026-05-21 17:10:23 +0200
commit2e614665e0c62fe572b5af53a2a6be1116d66720 (patch)
treedbb48ad5d665a3f3d8c793db30a523264f17bcde /cogs/music/audio_playback.py
parentfix naming not resolving things like %20 (diff)
parentFix voice reconnect race: reuse VoiceClient instead of destroying it (diff)
downloadsenator-sperma-2e614665e0c62fe572b5af53a2a6be1116d66720.tar.gz
senator-sperma-2e614665e0c62fe572b5af53a2a6be1116d66720.zip
Merge pull request 'Voice related fixes, alongside simplifying assistant' (#5) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/5
Diffstat (limited to '')
-rw-r--r--cogs/music/audio_playback.py282
1 files changed, 0 insertions, 282 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
deleted file mode 100644
index 1313d01..0000000
--- a/cogs/music/audio_playback.py
+++ /dev/null
@@ -1,282 +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 asyncio
7import logging
8import os
9import urllib.parse
10
11import discord
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 logger.info(f"Moving to voice channel: {target_channel.name}")
178 await vc.move_to(target_channel)
179
180 # Stop any currently playing audio (if interrupt is True)
181 if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr]
182 vc.stop() # type: ignore[union-attr]
183 elif not interrupt and vc.is_playing(): # type: ignore[union-attr]
184 logger.info("Audio already playing and interrupt=False, skipping playback")
185 return False
186
187 # Prepare audio source
188 (
189 audio_source,
190 final_display_name,
191 ) = await AudioPlaybackManager.prepare_audio_source(
192 source, is_url, display_name
193 )
194
195 # Track playback state
196 playback_state = {"error": None, "finished": False}
197
198 def after_playing(error):
199 if error:
200 playback_state["error"] = str(error)
201 logger.error(f"Playback error: {error}")
202 playback_state["finished"] = True
203
204 # Play the audio
205 vc.play(audio_source, after=after_playing)
206 logger.info(f"Playing: {final_display_name}")
207 await ctx.followup.send(f"grom: {final_display_name}")
208
209 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
210 await asyncio.sleep(5)
211
212 # Check if playback failed to start (not just finished quickly)
213 if playback_state["error"]:
214 logger.error(f"Playback failed: {playback_state['error']}")
215 await ctx.followup.send(
216 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
217 )
218 return False
219
220 # If playback finished within 5s, it was a short file - not an error
221 if playback_state["finished"]:
222 logger.info(f"Playback completed quickly: {final_display_name}")
223 return True
224
225 if not vc.is_playing():
226 logger.error(f"Stream failed - not playing after 5s: {source}")
227 await ctx.followup.send(
228 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
229 )
230 return False
231
232 return True
233
234 except Exception as e:
235 logger.error(f"Error in play: {str(e)}", exc_info=True)
236 await ctx.followup.send(f"eror: {str(e)}", ephemeral=True)
237 return False
238
239 @staticmethod
240 async def validate_and_prepare_source(ctx, source):
241 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
242 (
243 prepared_source,
244 display_name,
245 is_url,
246 ) = await AudioPlaybackManager.prepare_source(ctx, source)
247 if not prepared_source:
248 return None, None, None
249 return prepared_source, display_name, is_url
250
251 @staticmethod
252 async def play(ctx, source, delete_after_play=False, interrupt=True):
253 """Play audio from a local file or URL.
254
255 Args:
256 ctx: Discord context
257 source: Either a local file path or a URL to stream from
258 delete_after_play: If True, delete the file after playing (only for local files)
259 interrupt: If True, stop any currently playing audio. If False, skip if audio is playing.
260 """
261 # Validate and prepare source
262 (
263 prepared_source,
264 display_name,
265 is_url,
266 ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
267 if not prepared_source:
268 await AudioPlaybackManager.cleanup_if_needed(
269 source, delete_after_play, False
270 )
271 return
272
273 # Execute playback
274 success = await AudioPlaybackManager.execute_playback(
275 ctx, prepared_source, display_name, is_url, interrupt
276 )
277
278 # Cleanup if needed
279 if not success:
280 await AudioPlaybackManager.cleanup_if_needed(
281 prepared_source, delete_after_play, is_url
282 )