diff options
Diffstat (limited to 'cogs/music/audio_playback.py')
| -rw-r--r-- | cogs/music/audio_playback.py | 282 |
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 | |||
| 6 | import asyncio | ||
| 7 | import logging | ||
| 8 | import os | ||
| 9 | import urllib.parse | ||
| 10 | |||
| 11 | import discord | ||
| 12 | import mutagen | ||
| 13 | |||
| 14 | logger = logging.getLogger(__name__) | ||
| 15 | |||
| 16 | |||
| 17 | class 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 | ) | ||
