aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/audio_playback.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-04-17 15:36:12 +0200
committersalmonik <raisersoft@proton.me>2026-04-17 15:36:12 +0200
commitd135265798da8c2698f363237f4cb74b682f3f9e (patch)
treebca914d297e74a6188478a773ba7333c10fe4a34 /cogs/music/audio_playback.py
parentMerge branch 'main' of https://codeberg.org/salmonik/senator-sperma (diff)
downloadsenator-sperma-d135265798da8c2698f363237f4cb74b682f3f9e.tar.gz
senator-sperma-d135265798da8c2698f363237f4cb74b682f3f9e.zip
make voice great again
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'cogs/music/audio_playback.py')
-rw-r--r--cogs/music/audio_playback.py121
1 files changed, 90 insertions, 31 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
index 5632728..75608b8 100644
--- a/cogs/music/audio_playback.py
+++ b/cogs/music/audio_playback.py
@@ -3,17 +3,20 @@
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 os 6import asyncio
7import logging 7import logging
8import os
8import urllib.parse 9import urllib.parse
10
9import discord 11import discord
10import mutagen 12import mutagen
11 13
12logger = logging.getLogger(__name__) 14logger = logging.getLogger(__name__)
13 15
16
14class AudioPlaybackManager: 17class AudioPlaybackManager:
15 """Handles audio playback, voice connections, and source preparation.""" 18 """Handles audio playback, voice connections, and source preparation."""
16 19
17 @staticmethod 20 @staticmethod
18 async def cleanup_temp_file(source): 21 async def cleanup_temp_file(source):
19 """Clean up temporary file if it exists.""" 22 """Clean up temporary file if it exists."""
@@ -28,7 +31,9 @@ class AudioPlaybackManager:
28 async def validate_voice_channel(ctx): 31 async def validate_voice_channel(ctx):
29 """Validate user is in a voice channel. Returns channel or None.""" 32 """Validate user is in a voice channel. Returns channel or None."""
30 if not ctx.author.voice or not ctx.author.voice.channel: 33 if not ctx.author.voice or not ctx.author.voice.channel:
31 await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) 34 await ctx.respond(
35 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
36 )
32 return None 37 return None
33 return ctx.author.voice.channel 38 return ctx.author.voice.channel
34 39
@@ -36,16 +41,16 @@ class AudioPlaybackManager:
36 async def prepare_file_source(source): 41 async def prepare_file_source(source):
37 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" 42 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None)."""
38 logger.info(f"Attempting to play local file: {source}") 43 logger.info(f"Attempting to play local file: {source}")
39 44
40 # Get absolute path and verify file exists 45 # Get absolute path and verify file exists
41 if not os.path.isabs(source): 46 if not os.path.isabs(source):
42 source = os.path.abspath(source) 47 source = os.path.abspath(source)
43 logger.debug(f"Converted to absolute path: {source}") 48 logger.debug(f"Converted to absolute path: {source}")
44 49
45 if not os.path.exists(source): 50 if not os.path.exists(source):
46 logger.error(f"File not found: {source}") 51 logger.error(f"File not found: {source}")
47 return None, None 52 return None, None
48 53
49 filename = os.path.basename(source) 54 filename = os.path.basename(source)
50 display_name = os.path.splitext(filename)[0] 55 display_name = os.path.splitext(filename)[0]
51 return display_name, source 56 return display_name, source
@@ -77,24 +82,28 @@ class AudioPlaybackManager:
77 if is_url: 82 if is_url:
78 # For URLs, use FFmpeg with the direct URL and extract clean filename 83 # For URLs, use FFmpeg with the direct URL and extract clean filename
79 ffmpeg_options = { 84 ffmpeg_options = {
80 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', 85 "options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
81 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' 86 "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
82 } 87 }
83 audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) 88 audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options)
84 # Extract clean filename for URL (remove extension and decode URL encoding) 89 # Extract clean filename for URL (remove extension and decode URL encoding)
85 filename = os.path.basename(source.split('?')[0]) # Remove query parameters 90 filename = os.path.basename(source.split("?")[0]) # Remove query parameters
86 filename = urllib.parse.unquote(filename) # Decode URL encoding 91 filename = urllib.parse.unquote(filename) # Decode URL encoding
87 clean_filename = os.path.splitext(filename)[0] # Remove file extension 92 clean_filename = os.path.splitext(filename)[0] # Remove file extension
88 return audio_source, clean_filename 93 return audio_source, clean_filename
89 else: 94 else:
90 # For local files, use default options 95 # For local files, use default options
91 audio_source = discord.FFmpegPCMAudio(source=source) 96 audio_source = discord.FFmpegPCMAudio(source=source)
92 97
93 # Try to get metadata for local files 98 # Try to get metadata for local files
94 try: 99 try:
95 metadata = mutagen.File(source) 100 metadata = mutagen.File(source)
96 if metadata: 101 if metadata:
97 title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None 102 title = (
103 metadata.get("title", [None])[0]
104 if hasattr(metadata, "get")
105 else None
106 )
98 if title: 107 if title:
99 display_name = title 108 display_name = title
100 logger.debug(f"Read metadata - title: {title}") 109 logger.debug(f"Read metadata - title: {title}")
@@ -103,7 +112,7 @@ class AudioPlaybackManager:
103 except Exception as e: 112 except Exception as e:
104 logger.warning(f"Could not read metadata from {source}: {str(e)}") 113 logger.warning(f"Could not read metadata from {source}: {str(e)}")
105 display_name = os.path.splitext(os.path.basename(source))[0] 114 display_name = os.path.splitext(os.path.basename(source))[0]
106 115
107 return audio_source, display_name 116 return audio_source, display_name
108 117
109 @staticmethod 118 @staticmethod
@@ -115,16 +124,18 @@ class AudioPlaybackManager:
115 @staticmethod 124 @staticmethod
116 async def prepare_source(ctx, source): 125 async def prepare_source(ctx, source):
117 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" 126 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None)."""
118 is_url = source.startswith(('http://', 'https://')) 127 is_url = source.startswith(("http://", "https://"))
119 128
120 if is_url: 129 if is_url:
121 logger.info(f"Attempting to play stream from URL: {source}") 130 logger.info(f"Attempting to play stream from URL: {source}")
122 return source, source, is_url 131 return source, source, is_url
123 132
124 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source) 133 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(
134 source
135 )
125 if not display_name: 136 if not display_name:
126 return None, None, None 137 return None, None, None
127 138
128 return absolute_source, display_name, is_url 139 return absolute_source, display_name, is_url
129 140
130 @staticmethod 141 @staticmethod
@@ -134,23 +145,57 @@ class AudioPlaybackManager:
134 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) 145 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
135 if not target_channel: 146 if not target_channel:
136 return False 147 return False
137 148
138 try: 149 try:
139 vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel) 150 vc = await AudioPlaybackManager.connect_to_voice_channel(
140 151 ctx, vc, target_channel
152 )
153
141 # Stop any currently playing audio 154 # Stop any currently playing audio
142 if vc.is_playing() or vc.is_paused(): 155 if vc.is_playing() or vc.is_paused():
143 vc.stop() 156 vc.stop()
144 157
145 # Prepare audio source 158 # Prepare audio source
146 audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name) 159 (
160 audio_source,
161 final_display_name,
162 ) = await AudioPlaybackManager.prepare_audio_source(
163 source, is_url, display_name
164 )
165
166 # Track if playback failed
167 playback_failed = {"error": None}
168
169 def after_playing(error):
170 if error:
171 playback_failed["error"] = str(error)
172 logger.error(f"Playback error: {error}")
147 173
148 # Play the audio 174 # Play the audio
149 vc.play(audio_source) 175 vc.play(audio_source, after=after_playing)
150 logger.info(f"Playing: {final_display_name}") 176 logger.info(f"Playing: {final_display_name}")
151 await ctx.respond(f"grom: {final_display_name}") 177 await ctx.respond(f"grom: {final_display_name}")
178
179 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
180 await asyncio.sleep(5)
181
182 # Check if playback actually started
183 if playback_failed["error"]:
184 logger.error(f"Playback failed: {playback_failed['error']}")
185 await ctx.followup.send(
186 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
187 )
188 return False
189
190 if not vc.is_playing():
191 logger.error(f"Stream failed - not playing after 5s: {source}")
192 await ctx.followup.send(
193 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
194 )
195 return False
196
152 return True 197 return True
153 198
154 except Exception as e: 199 except Exception as e:
155 logger.error(f"Error in play: {str(e)}", exc_info=True) 200 logger.error(f"Error in play: {str(e)}", exc_info=True)
156 await ctx.respond(f"eror: {str(e)}", ephemeral=True) 201 await ctx.respond(f"eror: {str(e)}", ephemeral=True)
@@ -159,7 +204,11 @@ class AudioPlaybackManager:
159 @staticmethod 204 @staticmethod
160 async def validate_and_prepare_source(ctx, source): 205 async def validate_and_prepare_source(ctx, source):
161 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" 206 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
162 prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source) 207 (
208 prepared_source,
209 display_name,
210 is_url,
211 ) = await AudioPlaybackManager.prepare_source(ctx, source)
163 if not prepared_source: 212 if not prepared_source:
164 return None, None, None 213 return None, None, None
165 return prepared_source, display_name, is_url 214 return prepared_source, display_name, is_url
@@ -167,21 +216,31 @@ class AudioPlaybackManager:
167 @staticmethod 216 @staticmethod
168 async def play(ctx, source, delete_after_play=False): 217 async def play(ctx, source, delete_after_play=False):
169 """Play audio from a local file or URL. 218 """Play audio from a local file or URL.
170 219
171 Args: 220 Args:
172 ctx: Discord context 221 ctx: Discord context
173 source: Either a local file path or a URL to stream from 222 source: Either a local file path or a URL to stream from
174 delete_after_play: If True, delete the file after playing (only for local files) 223 delete_after_play: If True, delete the file after playing (only for local files)
175 """ 224 """
176 # Validate and prepare source 225 # Validate and prepare source
177 prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) 226 (
227 prepared_source,
228 display_name,
229 is_url,
230 ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
178 if not prepared_source: 231 if not prepared_source:
179 await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False) 232 await AudioPlaybackManager.cleanup_if_needed(
233 source, delete_after_play, False
234 )
180 return 235 return
181 236
182 # Execute playback 237 # Execute playback
183 success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url) 238 success = await AudioPlaybackManager.execute_playback(
184 239 ctx, prepared_source, display_name, is_url
240 )
241
185 # Cleanup if needed 242 # Cleanup if needed
186 if not success: 243 if not success:
187 await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) 244 await AudioPlaybackManager.cleanup_if_needed(
245 prepared_source, delete_after_play, is_url
246 )