diff options
Diffstat (limited to '')
| -rw-r--r-- | cogs/music/audio_playback.py | 121 | ||||
| -rw-r--r-- | cogs/music/join.py | 4 | ||||
| -rw-r--r-- | cogs/music/loop.py | 15 | ||||
| -rw-r--r-- | cogs/music/media_processing.py | 67 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 184 | ||||
| -rw-r--r-- | cogs/tts.py | 8 | ||||
| -rw-r--r-- | cogs/vclogs.py | 55 |
7 files changed, 273 insertions, 181 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 | ) | ||
diff --git a/cogs/music/join.py b/cogs/music/join.py index a12b842..c613b10 100644 --- a/cogs/music/join.py +++ b/cogs/music/join.py | |||
| @@ -3,10 +3,10 @@ | |||
| 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 | from discord.ext import commands | ||
| 7 | |||
| 8 | import logging | 6 | import logging |
| 9 | 7 | ||
| 8 | from discord.ext import commands | ||
| 9 | |||
| 10 | logger = logging.getLogger(__name__) | 10 | logger = logging.getLogger(__name__) |
| 11 | 11 | ||
| 12 | 12 | ||
diff --git a/cogs/music/loop.py b/cogs/music/loop.py index 34fc04c..6e2c838 100644 --- a/cogs/music/loop.py +++ b/cogs/music/loop.py | |||
| @@ -3,9 +3,10 @@ | |||
| 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 | from discord.ext import commands | ||
| 7 | import logging | 6 | import logging |
| 8 | 7 | ||
| 8 | from discord.ext import commands | ||
| 9 | |||
| 9 | logger = logging.getLogger(__name__) | 10 | logger = logging.getLogger(__name__) |
| 10 | 11 | ||
| 11 | 12 | ||
| @@ -20,13 +21,19 @@ class Loop(commands.Cog): | |||
| 20 | def set_looping(self, guild_id, value): | 21 | def set_looping(self, guild_id, value): |
| 21 | self.looping[guild_id] = value | 22 | self.looping[guild_id] = value |
| 22 | 23 | ||
| 23 | @commands.slash_command(name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji") | 24 | @commands.slash_command( |
| 25 | name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" | ||
| 26 | ) | ||
| 24 | async def loop(self, ctx): | 27 | async def loop(self, ctx): |
| 25 | guild_id = ctx.guild.id | 28 | guild_id = ctx.guild.id |
| 26 | looping = not self.is_looping(guild_id) | 29 | looping = not self.is_looping(guild_id) |
| 27 | self.set_looping(guild_id, looping) | 30 | self.set_looping(guild_id, looping) |
| 28 | await ctx.respond(f"looping jest teraz {'włączony' if looping else 'wyłączony'}.") | 31 | await ctx.respond( |
| 29 | logger.info(f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}") | 32 | f"looping jest teraz {'włączony' if looping else 'wyłączony'}." |
| 33 | ) | ||
| 34 | logger.info( | ||
| 35 | f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" | ||
| 36 | ) | ||
| 30 | 37 | ||
| 31 | 38 | ||
| 32 | def setup(bot): | 39 | def setup(bot): |
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py index 430b51f..e04911c 100644 --- a/cogs/music/media_processing.py +++ b/cogs/music/media_processing.py | |||
| @@ -3,26 +3,26 @@ | |||
| 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 | ||
| 7 | import asyncio | 6 | import asyncio |
| 8 | import logging | 7 | import logging |
| 9 | from pathlib import Path | 8 | import os |
| 10 | 9 | ||
| 11 | import yt_dlp | 10 | import yt_dlp |
| 12 | from youtubesearchpython import VideosSearch | 11 | from youtubesearchpython import VideosSearch |
| 13 | 12 | ||
| 14 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 15 | 14 | ||
| 15 | |||
| 16 | class MediaDownloader: | 16 | class MediaDownloader: |
| 17 | """Handles downloading media from various sources.""" | 17 | """Handles downloading media from various sources.""" |
| 18 | 18 | ||
| 19 | @staticmethod | 19 | @staticmethod |
| 20 | async def download(ctx, url): | 20 | async def download(ctx, url): |
| 21 | """Download audio from YouTube URL.""" | 21 | """Download audio from YouTube URL.""" |
| 22 | # Create temp directory if it doesn't exist | 22 | # Create temp directory if it doesn't exist |
| 23 | temp_dir = os.path.join('cogs', 'temp', 'music') | 23 | temp_dir = os.path.join("cogs", "temp", "music") |
| 24 | os.makedirs(temp_dir, exist_ok=True) | 24 | os.makedirs(temp_dir, exist_ok=True) |
| 25 | 25 | ||
| 26 | ytdl_opts = { | 26 | ytdl_opts = { |
| 27 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), | 27 | "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), |
| 28 | "postprocessors": [ | 28 | "postprocessors": [ |
| @@ -62,9 +62,10 @@ class MediaDownloader: | |||
| 62 | await ctx.respond("nic ni ma", ephemeral=True) | 62 | await ctx.respond("nic ni ma", ephemeral=True) |
| 63 | return None | 63 | return None |
| 64 | 64 | ||
| 65 | |||
| 65 | class AudioCompressor: | 66 | class AudioCompressor: |
| 66 | """Handles audio compression using FFmpeg.""" | 67 | """Handles audio compression using FFmpeg.""" |
| 67 | 68 | ||
| 68 | @staticmethod | 69 | @staticmethod |
| 69 | async def compress(ctx, filename): | 70 | async def compress(ctx, filename): |
| 70 | """Compress audio file to opus format.""" | 71 | """Compress audio file to opus format.""" |
| @@ -73,51 +74,63 @@ class AudioCompressor: | |||
| 73 | return None | 74 | return None |
| 74 | 75 | ||
| 75 | # Create temp directory if it doesn't exist | 76 | # Create temp directory if it doesn't exist |
| 76 | temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'compressed') | 77 | temp_dir = os.path.join( |
| 78 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), | ||
| 79 | "temp", | ||
| 80 | "compressed", | ||
| 81 | ) | ||
| 77 | os.makedirs(temp_dir, exist_ok=True) | 82 | os.makedirs(temp_dir, exist_ok=True) |
| 78 | 83 | ||
| 79 | # Create a temporary output filename in the temp directory | 84 | # Create a temporary output filename in the temp directory |
| 80 | base_name = os.path.basename(filename) | 85 | base_name = os.path.basename(filename) |
| 81 | output_filename = os.path.join(temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus") | 86 | output_filename = os.path.join( |
| 82 | 87 | temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" | |
| 88 | ) | ||
| 89 | |||
| 83 | # Ensure the output directory exists | 90 | # Ensure the output directory exists |
| 84 | os.makedirs(os.path.dirname(output_filename) or '.', exist_ok=True) | 91 | os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) |
| 85 | 92 | ||
| 86 | # Use proper quoting for filenames with spaces | 93 | # Use proper quoting for filenames with spaces |
| 87 | ffmpeg_command = [ | 94 | ffmpeg_command = [ |
| 88 | 'ffmpeg', | 95 | "ffmpeg", |
| 89 | '-y', # Overwrite output file if it exists | 96 | "-y", # Overwrite output file if it exists |
| 90 | '-i', filename, | 97 | "-i", |
| 91 | '-ac', '1', # Mono audio | 98 | filename, |
| 92 | '-b:a', '500', # 500k bitrate | 99 | "-ac", |
| 93 | '-acodec', 'libopus', | 100 | "1", # Mono audio |
| 94 | output_filename | 101 | "-b:a", |
| 102 | "500", # 500k bitrate | ||
| 103 | "-acodec", | ||
| 104 | "libopus", | ||
| 105 | output_filename, | ||
| 95 | ] | 106 | ] |
| 96 | 107 | ||
| 97 | try: | 108 | try: |
| 98 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") | 109 | logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") |
| 99 | process = await asyncio.create_subprocess_exec( | 110 | process = await asyncio.create_subprocess_exec( |
| 100 | *ffmpeg_command, | 111 | *ffmpeg_command, |
| 101 | stdout=asyncio.subprocess.PIPE, | 112 | stdout=asyncio.subprocess.PIPE, |
| 102 | stderr=asyncio.subprocess.PIPE | 113 | stderr=asyncio.subprocess.PIPE, |
| 103 | ) | 114 | ) |
| 104 | 115 | ||
| 105 | _, stderr = await process.communicate() | 116 | _, stderr = await process.communicate() |
| 106 | 117 | ||
| 107 | if process.returncode != 0: | 118 | if process.returncode != 0: |
| 108 | error_msg = stderr.decode() | 119 | error_msg = stderr.decode() |
| 109 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") | 120 | logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") |
| 110 | await ctx.respond("eror kompresujync pilk", ephemeral=True) | 121 | await ctx.respond("eror kompresujync pilk", ephemeral=True) |
| 111 | return None | 122 | return None |
| 112 | 123 | ||
| 113 | if not os.path.exists(output_filename): | 124 | if not os.path.exists(output_filename): |
| 114 | logger.error(f"Output file was not created: {output_filename}") | 125 | logger.error(f"Output file was not created: {output_filename}") |
| 115 | await ctx.respond("eror kompresujync pilk - output file not created", ephemeral=True) | 126 | await ctx.respond( |
| 127 | "eror kompresujync pilk - output file not created", ephemeral=True | ||
| 128 | ) | ||
| 116 | return None | 129 | return None |
| 117 | 130 | ||
| 118 | logger.info(f"Successfully compressed file: {output_filename}") | 131 | logger.info(f"Successfully compressed file: {output_filename}") |
| 119 | return output_filename | 132 | return output_filename |
| 120 | 133 | ||
| 121 | except Exception as e: | 134 | except Exception as e: |
| 122 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) | 135 | logger.error(f"Error compressing file: {str(e)}", exc_info=True) |
| 123 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) | 136 | await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index a06c636..a24169f 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -3,88 +3,102 @@ | |||
| 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 logging | ||
| 6 | import os | 7 | import os |
| 7 | import urllib.parse | 8 | import urllib.parse |
| 8 | import discord | ||
| 9 | from typing import List, Tuple, Callable, Awaitable | ||
| 10 | from os import PathLike | 9 | from os import PathLike |
| 10 | from typing import Awaitable, Callable, List, Tuple | ||
| 11 | |||
| 12 | import discord | ||
| 13 | from discord.commands import OptionChoice | ||
| 14 | from discord.ext import commands | ||
| 11 | 15 | ||
| 12 | from cogs.music import audio_playback, media_processing | 16 | from cogs.music import audio_playback, media_processing |
| 13 | from cogs.utils import file_search as search_utils | 17 | from cogs.utils import file_search as search_utils |
| 14 | from discord.ext import commands | ||
| 15 | from discord.commands import OptionChoice | ||
| 16 | import logging | ||
| 17 | 18 | ||
| 18 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 19 | 20 | ||
| 21 | |||
| 20 | class PlayCommands(commands.Cog): | 22 | class PlayCommands(commands.Cog): |
| 21 | """Discord slash commands for music playback.""" | 23 | """Discord slash commands for music playback.""" |
| 22 | 24 | ||
| 23 | def __init__(self, bot): | 25 | def __init__(self, bot): |
| 24 | self.bot = bot | 26 | self.bot = bot |
| 25 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") | 27 | self.temp_dir = os.path.join( |
| 28 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp" | ||
| 29 | ) | ||
| 26 | self.path_mapping = {} # Maps display names to full paths | 30 | self.path_mapping = {} # Maps display names to full paths |
| 27 | 31 | ||
| 28 | @staticmethod | 32 | @staticmethod |
| 29 | async def _create_search_embed(candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: | 33 | async def _create_search_embed( |
| 34 | candidates: List[str], | ||
| 35 | ) -> Tuple[discord.Embed, discord.ui.View]: | ||
| 30 | """Create an embed and view for search results.""" | 36 | """Create an embed and view for search results.""" |
| 31 | embed = discord.Embed( | 37 | embed = discord.Embed( |
| 32 | title="Wyniki wyszukiwania", | 38 | title="Wyniki wyszukiwania", |
| 33 | description="Wybierz plik do odtworzenia:", | 39 | description="Wybierz plik do odtworzenia:", |
| 34 | color=discord.Color.blue() | 40 | color=discord.Color.blue(), |
| 35 | ) | 41 | ) |
| 36 | 42 | ||
| 37 | view = discord.ui.View(disable_on_timeout=True) | 43 | view = discord.ui.View(disable_on_timeout=True) |
| 38 | 44 | ||
| 39 | for i, candidate in enumerate(candidates, start=1): | 45 | for i, candidate in enumerate(candidates, start=1): |
| 40 | filename = candidate.split('/')[-1].split('?')[0] | 46 | filename = candidate.split("/")[-1].split("?")[0] |
| 41 | display_name = os.path.splitext(filename)[0] | 47 | display_name = os.path.splitext(filename)[0] |
| 42 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | 48 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) |
| 43 | 49 | ||
| 44 | return embed, view | 50 | return embed, view |
| 45 | 51 | ||
| 46 | async def _handle_search_results( | 52 | async def _handle_search_results( |
| 47 | self, | 53 | self, |
| 48 | ctx: discord.ApplicationContext, | 54 | ctx: discord.ApplicationContext, |
| 49 | candidates: List[str], | 55 | candidates: List[str], |
| 50 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]] | 56 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]], |
| 51 | ) -> None: | 57 | ) -> None: |
| 52 | """Handle the display of search results and button interactions.""" | 58 | """Handle the display of search results and button interactions.""" |
| 53 | if not candidates: | 59 | if not candidates: |
| 54 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | 60 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) |
| 55 | return | 61 | return |
| 56 | 62 | ||
| 57 | embed, view = await self._create_search_embed(candidates) | 63 | embed, view = await self._create_search_embed(candidates) |
| 58 | 64 | ||
| 59 | # Add buttons for each candidate | 65 | # Add buttons for each candidate |
| 60 | for i, candidate in enumerate(candidates, start=1): | 66 | for i, candidate in enumerate(candidates, start=1): |
| 61 | button = discord.ui.Button(label=f"Play {i}") | 67 | button = discord.ui.Button(label=f"Play {i}") |
| 62 | 68 | ||
| 63 | async def button_callback(interaction: discord.Interaction, candidate_path=candidate): | 69 | async def button_callback( |
| 70 | interaction: discord.Interaction, candidate_path=candidate | ||
| 71 | ): | ||
| 64 | await interaction.response.defer(ephemeral=True) | 72 | await interaction.response.defer(ephemeral=True) |
| 65 | await ctx.interaction.delete_original_response() | 73 | await ctx.interaction.delete_original_response() |
| 66 | try: | 74 | try: |
| 67 | await play_callback(interaction, candidate_path) | 75 | await play_callback(interaction, candidate_path) |
| 68 | except Exception as e: | 76 | except Exception as e: |
| 69 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) | 77 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) |
| 70 | await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | 78 | await interaction.followup.send( |
| 71 | 79 | f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True | |
| 80 | ) | ||
| 81 | |||
| 72 | button.callback = button_callback | 82 | button.callback = button_callback |
| 73 | view.add_item(button) | 83 | view.add_item(button) |
| 74 | 84 | ||
| 75 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 85 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 76 | 86 | ||
| 77 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: | 87 | async def _search_autocomplete( |
| 88 | self, ctx: discord.AutocompleteContext | ||
| 89 | ) -> List[OptionChoice]: | ||
| 78 | """Autocomplete handler for search queries.""" | 90 | """Autocomplete handler for search queries.""" |
| 79 | query = ctx.value.lower() | 91 | query = ctx.value.lower() |
| 80 | try: | 92 | try: |
| 81 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio(query, max_results=25) | 93 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( |
| 94 | query, max_results=25 | ||
| 95 | ) | ||
| 82 | choices = [] | 96 | choices = [] |
| 83 | self.path_mapping = {} # Reset the mapping for each new query | 97 | self.path_mapping = {} # Reset the mapping for each new query |
| 84 | 98 | ||
| 85 | for url in results: | 99 | for url in results: |
| 86 | filename = url.split('/')[-1] | 100 | filename = url.split("/")[-1] |
| 87 | filename = filename.split('?')[0] # Remove query params if any | 101 | filename = filename.split("?")[0] # Remove query params if any |
| 88 | filename = urllib.parse.unquote(filename) # Decode URL encoding | 102 | filename = urllib.parse.unquote(filename) # Decode URL encoding |
| 89 | display = os.path.splitext(filename)[0] # Remove extension | 103 | display = os.path.splitext(filename)[0] # Remove extension |
| 90 | 104 | ||
| @@ -93,7 +107,7 @@ class PlayCommands(commands.Cog): | |||
| 93 | self.path_mapping[display] = url | 107 | self.path_mapping[display] = url |
| 94 | 108 | ||
| 95 | if len(display) > 90: | 109 | if len(display) > 90: |
| 96 | display = display[:87] + '...' | 110 | display = display[:87] + "..." |
| 97 | 111 | ||
| 98 | choices.append(OptionChoice(display[:100], display[:100])) | 112 | choices.append(OptionChoice(display[:100], display[:100])) |
| 99 | 113 | ||
| @@ -106,25 +120,12 @@ class PlayCommands(commands.Cog): | |||
| 106 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | 120 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) |
| 107 | return [] | 121 | return [] |
| 108 | 122 | ||
| 109 | async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: | 123 | async def _handle_compressed_file_play( |
| 110 | """Validate input parameters for play command.""" | 124 | self, ctx: discord.ApplicationContext, url: str |
| 111 | if not file and not stream: | 125 | ) -> None: |
| 112 | await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | ||
| 113 | return False | ||
| 114 | |||
| 115 | if file and stream: | ||
| 116 | await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | ||
| 117 | return False | ||
| 118 | |||
| 119 | return True | ||
| 120 | |||
| 121 | async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None: | ||
| 122 | """Handle internet stream playback.""" | ||
| 123 | await audio_playback.AudioPlaybackManager.play(ctx, stream) | ||
| 124 | |||
| 125 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None: | ||
| 126 | """Download from copyparty, compress, then play locally.""" | 126 | """Download from copyparty, compress, then play locally.""" |
| 127 | import aiohttp | 127 | import aiohttp |
| 128 | |||
| 128 | local_path = None | 129 | local_path = None |
| 129 | compressed_path = None | 130 | compressed_path = None |
| 130 | try: | 131 | try: |
| @@ -133,21 +134,25 @@ class PlayCommands(commands.Cog): | |||
| 133 | if response.status != 200: | 134 | if response.status != 200: |
| 134 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | 135 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) |
| 135 | return | 136 | return |
| 136 | 137 | ||
| 137 | filename = url.split('/')[-1].split('?')[0] | 138 | filename = url.split("/")[-1].split("?")[0] |
| 138 | filename = urllib.parse.unquote(filename) | 139 | filename = urllib.parse.unquote(filename) |
| 139 | local_path = os.path.join(self.temp_dir, filename) | 140 | local_path = os.path.join(self.temp_dir, filename) |
| 140 | os.makedirs(self.temp_dir, exist_ok=True) | 141 | os.makedirs(self.temp_dir, exist_ok=True) |
| 141 | 142 | ||
| 142 | with open(local_path, 'wb') as f: | 143 | with open(local_path, "wb") as f: |
| 143 | async for chunk in response.content.iter_chunked(8192): | 144 | async for chunk in response.content.iter_chunked(8192): |
| 144 | f.write(chunk) | 145 | f.write(chunk) |
| 145 | 146 | ||
| 146 | compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) | 147 | compressed_path = await media_processing.AudioCompressor.compress( |
| 148 | ctx, local_path | ||
| 149 | ) | ||
| 147 | if not compressed_path: | 150 | if not compressed_path: |
| 148 | return | 151 | return |
| 149 | 152 | ||
| 150 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | 153 | await audio_playback.AudioPlaybackManager.play( |
| 154 | ctx, compressed_path, delete_after_play=True | ||
| 155 | ) | ||
| 151 | except Exception as e: | 156 | except Exception as e: |
| 152 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) | 157 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) |
| 153 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) | 158 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) |
| @@ -158,10 +163,12 @@ class PlayCommands(commands.Cog): | |||
| 158 | except Exception: | 163 | except Exception: |
| 159 | pass | 164 | pass |
| 160 | 165 | ||
| 161 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: | 166 | async def _handle_local_file_play( |
| 167 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | ||
| 168 | ) -> None: | ||
| 162 | """Handle file playback from copyparty URL.""" | 169 | """Handle file playback from copyparty URL.""" |
| 163 | url = self.path_mapping.get(file, file) | 170 | url = self.path_mapping.get(file, file) |
| 164 | 171 | ||
| 165 | if compressed: | 172 | if compressed: |
| 166 | await self._handle_compressed_file_play(ctx, url) | 173 | await self._handle_compressed_file_play(ctx, url) |
| 167 | else: | 174 | else: |
| @@ -169,46 +176,47 @@ class PlayCommands(commands.Cog): | |||
| 169 | 176 | ||
| 170 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 177 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 171 | async def play( | 178 | async def play( |
| 172 | self, | 179 | self, |
| 173 | ctx: discord.ApplicationContext, | 180 | ctx: discord.ApplicationContext, |
| 174 | file: str = discord.Option( | 181 | melodia: str = discord.Option( |
| 175 | str, | 182 | str, |
| 176 | "wyszukaj plik z serwera", | 183 | "odtwórz plik z serwera bądź strumienia", |
| 177 | autocomplete=_search_autocomplete, | 184 | autocomplete=_search_autocomplete, |
| 178 | required=False | 185 | required=False, |
| 179 | ), | 186 | ), |
| 180 | stream: str = discord.Option( | 187 | file: discord.Attachment = discord.Option( |
| 181 | str, | 188 | discord.Attachment, "plik do zagrania", required=False |
| 182 | "odtwórz stream internetowy (e.g. radio)", | 189 | ), |
| 183 | required=False | 190 | compressed: bool = discord.Option( |
| 184 | ), | 191 | bool, "Czy skompresować dźwięk?", default=False |
| 185 | compressed: bool = discord.Option( | 192 | ), |
| 186 | bool, | ||
| 187 | "Czy skompresować dźwięk?", | ||
| 188 | default=False | ||
| 189 | ) | ||
| 190 | ): | 193 | ): |
| 191 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" | 194 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" |
| 192 | await ctx.defer() | 195 | await ctx.defer() |
| 193 | 196 | ||
| 194 | if not await self._validate_play_inputs(file, stream, ctx): | 197 | if melodia and file: |
| 198 | await ctx.respond( | ||
| 199 | "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True | ||
| 200 | ) | ||
| 195 | return | 201 | return |
| 196 | |||
| 197 | try: | ||
| 198 | if stream: | ||
| 199 | await self._handle_stream_play(ctx, stream) | ||
| 200 | else: | ||
| 201 | await self._handle_local_file_play(ctx, file, compressed) | ||
| 202 | except Exception as e: | ||
| 203 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 204 | await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | ||
| 205 | 202 | ||
| 206 | @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") | 203 | if not melodia and not file: |
| 207 | async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): | 204 | await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) |
| 208 | await ctx.defer() | 205 | return |
| 209 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) | 206 | |
| 210 | await attachment.save(file_path) | 207 | if file: |
| 211 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 208 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) |
| 209 | await file.save(file_path) | ||
| 210 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | ||
| 211 | else: | ||
| 212 | try: | ||
| 213 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 214 | except Exception as e: | ||
| 215 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 216 | await ctx.respond( | ||
| 217 | f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True | ||
| 218 | ) | ||
| 219 | |||
| 212 | 220 | ||
| 213 | def setup(bot): | 221 | def setup(bot): |
| 214 | bot.add_cog(PlayCommands(bot)) | 222 | bot.add_cog(PlayCommands(bot)) |
diff --git a/cogs/tts.py b/cogs/tts.py index 5e6945c..65f1fce 100644 --- a/cogs/tts.py +++ b/cogs/tts.py | |||
| @@ -94,7 +94,8 @@ class TTS(commands.Cog): | |||
| 94 | try: | 94 | try: |
| 95 | # Generate and save TTS to temp file | 95 | # Generate and save TTS to temp file |
| 96 | tts = gTTS(text=text, lang='pl') | 96 | tts = gTTS(text=text, lang='pl') |
| 97 | tts.save(temp_path) | 97 | loop = asyncio.get_event_loop() |
| 98 | await loop.run_in_executor(None, tts.save, temp_path) | ||
| 98 | 99 | ||
| 99 | # Load settings | 100 | # Load settings |
| 100 | volume = get_volume() | 101 | volume = get_volume() |
| @@ -104,7 +105,10 @@ class TTS(commands.Cog): | |||
| 104 | await self._handle_file_output(ctx, temp_path, text) | 105 | await self._handle_file_output(ctx, temp_path, text) |
| 105 | elif output == "vc": | 106 | elif output == "vc": |
| 106 | await self._handle_voice_output(ctx, temp_path, text, volume) | 107 | await self._handle_voice_output(ctx, temp_path, text, volume) |
| 107 | 108 | ||
| 109 | except Exception as e: | ||
| 110 | logger.error(f"TTS generation error: {e}") | ||
| 111 | await ctx.respond("Error generating TTS", ephemeral=True) | ||
| 108 | finally: | 112 | finally: |
| 109 | await self._cleanup_temp_file(temp_path) | 113 | await self._cleanup_temp_file(temp_path) |
| 110 | 114 | ||
diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 477aa1c..274d6ca 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py | |||
| @@ -76,40 +76,41 @@ class VcLogs(commands.Cog): | |||
| 76 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") | 76 | tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") |
| 77 | tts = gTTS(text=text, lang='pl') | 77 | tts = gTTS(text=text, lang='pl') |
| 78 | logger.info("saving file") | 78 | logger.info("saving file") |
| 79 | tts.save(tts_file) | ||
| 80 | |||
| 81 | # Get voice status using our new method | ||
| 82 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 83 | voice_client = voice_status[0] if voice_status else None | ||
| 84 | is_connected = voice_status[1] if voice_status else False | ||
| 85 | is_playing = voice_status[2] if voice_status else False | ||
| 86 | 79 | ||
| 87 | # Ensure we have a connected voice client | 80 | loop = asyncio.get_event_loop() |
| 88 | if not is_connected: | 81 | await loop.run_in_executor(None, tts.save, tts_file) |
| 89 | voice_client = await voice_mgr.join_channel(channel) | 82 | |
| 90 | # Update status after joining | 83 | try: |
| 91 | voice_status = await voice_mgr.get_voice_status(channel.guild) | 84 | voice_status = await voice_mgr.get_voice_status(channel.guild) |
| 92 | voice_client = voice_status[0] if voice_status else None | 85 | voice_client = voice_status[0] if voice_status else None |
| 93 | is_connected = voice_status[1] if voice_status else False | 86 | is_connected = voice_status[1] if voice_status else False |
| 94 | is_playing = voice_status[2] if voice_status else False | 87 | is_playing = voice_status[2] if voice_status else False |
| 95 | 88 | ||
| 96 | if not is_connected or voice_client is None: | 89 | if not is_connected: |
| 97 | logger.error("Failed to join voice channel") | 90 | voice_client = await voice_mgr.join_channel(channel) |
| 91 | voice_status = await voice_mgr.get_voice_status(channel.guild) | ||
| 92 | voice_client = voice_status[0] if voice_status else None | ||
| 93 | is_connected = voice_status[1] if voice_status else False | ||
| 94 | is_playing = voice_status[2] if voice_status else False | ||
| 95 | |||
| 96 | if not is_connected or voice_client is None: | ||
| 97 | logger.error("Failed to join voice channel") | ||
| 98 | return | ||
| 99 | |||
| 100 | volume = get_volume() | ||
| 101 | logger.info(f"connected: {voice_client}") | ||
| 102 | |||
| 103 | if voice_client and not is_playing: | ||
| 104 | logger.info("preparing to play audio") | ||
| 105 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | ||
| 106 | voice_client.play(audio_source) | ||
| 107 | while voice_client.is_playing(): | ||
| 108 | await asyncio.sleep(0.5) | ||
| 109 | except Exception as e: | ||
| 110 | logger.error(f"TTS playback error: {e}") | ||
| 111 | finally: | ||
| 98 | if os.path.exists(tts_file): | 112 | if os.path.exists(tts_file): |
| 99 | os.remove(tts_file) | 113 | os.remove(tts_file) |
| 100 | return | ||
| 101 | |||
| 102 | volume = get_volume() | ||
| 103 | logger.info(f"connected: {voice_client}") | ||
| 104 | |||
| 105 | if voice_client and not is_playing: | ||
| 106 | logger.info("preparing to play audio") | ||
| 107 | audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') | ||
| 108 | voice_client.play(audio_source) | ||
| 109 | while voice_client.is_playing(): | ||
| 110 | await asyncio.sleep(0.5) | ||
| 111 | if os.path.exists(tts_file): | ||
| 112 | os.remove(tts_file) | ||
| 113 | 114 | ||
| 114 | @commands.slash_command( | 115 | @commands.slash_command( |
| 115 | name="log_volume" | 116 | name="log_volume" |
