diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-17 15:36:12 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-17 15:36:12 +0200 |
| commit | d135265798da8c2698f363237f4cb74b682f3f9e (patch) | |
| tree | bca914d297e74a6188478a773ba7333c10fe4a34 /cogs/music/audio_playback.py | |
| parent | Merge branch 'main' of https://codeberg.org/salmonik/senator-sperma (diff) | |
| download | senator-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.py | 121 |
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 | ||
| 6 | import os | 6 | import asyncio |
| 7 | import logging | 7 | import logging |
| 8 | import os | ||
| 8 | import urllib.parse | 9 | import urllib.parse |
| 10 | |||
| 9 | import discord | 11 | import discord |
| 10 | import mutagen | 12 | import mutagen |
| 11 | 13 | ||
| 12 | logger = logging.getLogger(__name__) | 14 | logger = logging.getLogger(__name__) |
| 13 | 15 | ||
| 16 | |||
| 14 | class AudioPlaybackManager: | 17 | class 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 | ) | ||
