From d135265798da8c2698f363237f4cb74b682f3f9e Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 17 Apr 2026 15:36:12 +0200 Subject: make voice great again Signed-off-by: salmonik --- cogs/music/audio_playback.py | 121 ++++++++++++++++++++------- cogs/music/join.py | 4 +- cogs/music/loop.py | 15 +++- cogs/music/media_processing.py | 67 +++++++++------ cogs/music/play_commands.py | 184 +++++++++++++++++++++-------------------- 5 files changed, 239 insertions(+), 152 deletions(-) (limited to 'cogs/music') 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 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma -import os +import asyncio import logging +import os import urllib.parse + import discord import mutagen logger = logging.getLogger(__name__) + class AudioPlaybackManager: """Handles audio playback, voice connections, and source preparation.""" - + @staticmethod async def cleanup_temp_file(source): """Clean up temporary file if it exists.""" @@ -28,7 +31,9 @@ class AudioPlaybackManager: async def validate_voice_channel(ctx): """Validate user is in a voice channel. Returns channel or None.""" if not ctx.author.voice or not ctx.author.voice.channel: - await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) + await ctx.respond( + "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True + ) return None return ctx.author.voice.channel @@ -36,16 +41,16 @@ class AudioPlaybackManager: async def prepare_file_source(source): """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" logger.info(f"Attempting to play local file: {source}") - + # Get absolute path and verify file exists if not os.path.isabs(source): source = os.path.abspath(source) logger.debug(f"Converted to absolute path: {source}") - + if not os.path.exists(source): logger.error(f"File not found: {source}") return None, None - + filename = os.path.basename(source) display_name = os.path.splitext(filename)[0] return display_name, source @@ -77,24 +82,28 @@ class AudioPlaybackManager: if is_url: # For URLs, use FFmpeg with the direct URL and extract clean filename ffmpeg_options = { - 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', - 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' + "options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", + "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5", } audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) # Extract clean filename for URL (remove extension and decode URL encoding) - filename = os.path.basename(source.split('?')[0]) # Remove query parameters + filename = os.path.basename(source.split("?")[0]) # Remove query parameters filename = urllib.parse.unquote(filename) # Decode URL encoding clean_filename = os.path.splitext(filename)[0] # Remove file extension return audio_source, clean_filename else: # For local files, use default options audio_source = discord.FFmpegPCMAudio(source=source) - + # Try to get metadata for local files try: metadata = mutagen.File(source) if metadata: - title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None + title = ( + metadata.get("title", [None])[0] + if hasattr(metadata, "get") + else None + ) if title: display_name = title logger.debug(f"Read metadata - title: {title}") @@ -103,7 +112,7 @@ class AudioPlaybackManager: except Exception as e: logger.warning(f"Could not read metadata from {source}: {str(e)}") display_name = os.path.splitext(os.path.basename(source))[0] - + return audio_source, display_name @staticmethod @@ -115,16 +124,18 @@ class AudioPlaybackManager: @staticmethod async def prepare_source(ctx, source): """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" - is_url = source.startswith(('http://', 'https://')) - + is_url = source.startswith(("http://", "https://")) + if is_url: logger.info(f"Attempting to play stream from URL: {source}") return source, source, is_url - - display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source) + + display_name, absolute_source = await AudioPlaybackManager.prepare_file_source( + source + ) if not display_name: return None, None, None - + return absolute_source, display_name, is_url @staticmethod @@ -134,23 +145,57 @@ class AudioPlaybackManager: target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) if not target_channel: return False - + try: - vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel) - + vc = await AudioPlaybackManager.connect_to_voice_channel( + ctx, vc, target_channel + ) + # Stop any currently playing audio if vc.is_playing() or vc.is_paused(): vc.stop() # Prepare audio source - audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name) + ( + audio_source, + final_display_name, + ) = await AudioPlaybackManager.prepare_audio_source( + source, is_url, display_name + ) + + # Track if playback failed + playback_failed = {"error": None} + + def after_playing(error): + if error: + playback_failed["error"] = str(error) + logger.error(f"Playback error: {error}") # Play the audio - vc.play(audio_source) + vc.play(audio_source, after=after_playing) logger.info(f"Playing: {final_display_name}") await ctx.respond(f"grom: {final_display_name}") + + # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds + await asyncio.sleep(5) + + # Check if playback actually started + if playback_failed["error"]: + logger.error(f"Playback failed: {playback_failed['error']}") + await ctx.followup.send( + "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True + ) + return False + + if not vc.is_playing(): + logger.error(f"Stream failed - not playing after 5s: {source}") + await ctx.followup.send( + "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True + ) + return False + return True - + except Exception as e: logger.error(f"Error in play: {str(e)}", exc_info=True) await ctx.respond(f"eror: {str(e)}", ephemeral=True) @@ -159,7 +204,11 @@ class AudioPlaybackManager: @staticmethod async def validate_and_prepare_source(ctx, source): """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" - prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source) + ( + prepared_source, + display_name, + is_url, + ) = await AudioPlaybackManager.prepare_source(ctx, source) if not prepared_source: return None, None, None return prepared_source, display_name, is_url @@ -167,21 +216,31 @@ class AudioPlaybackManager: @staticmethod async def play(ctx, source, delete_after_play=False): """Play audio from a local file or URL. - + Args: ctx: Discord context source: Either a local file path or a URL to stream from delete_after_play: If True, delete the file after playing (only for local files) """ # Validate and prepare source - prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) + ( + prepared_source, + display_name, + is_url, + ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) if not prepared_source: - await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False) + await AudioPlaybackManager.cleanup_if_needed( + source, delete_after_play, False + ) return - + # Execute playback - success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url) - + success = await AudioPlaybackManager.execute_playback( + ctx, prepared_source, display_name, is_url + ) + # Cleanup if needed if not success: - await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) + await AudioPlaybackManager.cleanup_if_needed( + prepared_source, delete_after_play, is_url + ) 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 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma -from discord.ext import commands - import logging +from discord.ext import commands + logger = logging.getLogger(__name__) 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 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma -from discord.ext import commands import logging +from discord.ext import commands + logger = logging.getLogger(__name__) @@ -20,13 +21,19 @@ class Loop(commands.Cog): def set_looping(self, guild_id, value): self.looping[guild_id] = value - @commands.slash_command(name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji") + @commands.slash_command( + name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji" + ) async def loop(self, ctx): guild_id = ctx.guild.id looping = not self.is_looping(guild_id) self.set_looping(guild_id, looping) - await ctx.respond(f"looping jest teraz {'włączony' if looping else 'wyłączony'}.") - logger.info(f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}") + await ctx.respond( + f"looping jest teraz {'włączony' if looping else 'wyłączony'}." + ) + logger.info( + f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}" + ) 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 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma -import os import asyncio import logging -from pathlib import Path +import os import yt_dlp from youtubesearchpython import VideosSearch logger = logging.getLogger(__name__) + class MediaDownloader: """Handles downloading media from various sources.""" - + @staticmethod async def download(ctx, url): """Download audio from YouTube URL.""" # Create temp directory if it doesn't exist - temp_dir = os.path.join('cogs', 'temp', 'music') + temp_dir = os.path.join("cogs", "temp", "music") os.makedirs(temp_dir, exist_ok=True) - + ytdl_opts = { "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), "postprocessors": [ @@ -62,9 +62,10 @@ class MediaDownloader: await ctx.respond("nic ni ma", ephemeral=True) return None + class AudioCompressor: """Handles audio compression using FFmpeg.""" - + @staticmethod async def compress(ctx, filename): """Compress audio file to opus format.""" @@ -73,51 +74,63 @@ class AudioCompressor: return None # Create temp directory if it doesn't exist - temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'compressed') + temp_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "temp", + "compressed", + ) os.makedirs(temp_dir, exist_ok=True) - + # Create a temporary output filename in the temp directory base_name = os.path.basename(filename) - output_filename = os.path.join(temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus") - + output_filename = os.path.join( + temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus" + ) + # Ensure the output directory exists - os.makedirs(os.path.dirname(output_filename) or '.', exist_ok=True) - + os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True) + # Use proper quoting for filenames with spaces ffmpeg_command = [ - 'ffmpeg', - '-y', # Overwrite output file if it exists - '-i', filename, - '-ac', '1', # Mono audio - '-b:a', '500', # 500k bitrate - '-acodec', 'libopus', - output_filename + "ffmpeg", + "-y", # Overwrite output file if it exists + "-i", + filename, + "-ac", + "1", # Mono audio + "-b:a", + "500", # 500k bitrate + "-acodec", + "libopus", + output_filename, ] - + try: logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") process = await asyncio.create_subprocess_exec( *ffmpeg_command, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) - + _, stderr = await process.communicate() - + if process.returncode != 0: error_msg = stderr.decode() logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") await ctx.respond("eror kompresujync pilk", ephemeral=True) return None - + if not os.path.exists(output_filename): logger.error(f"Output file was not created: {output_filename}") - await ctx.respond("eror kompresujync pilk - output file not created", ephemeral=True) + await ctx.respond( + "eror kompresujync pilk - output file not created", ephemeral=True + ) return None - + logger.info(f"Successfully compressed file: {output_filename}") return output_filename - + except Exception as e: logger.error(f"Error compressing file: {str(e)}", exc_info=True) 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 @@ # Copyright (C) 2026 Salmonik @ salmonik@tuta.io # https://codeberg.org/salmonik/senator-sperma +import logging import os import urllib.parse -import discord -from typing import List, Tuple, Callable, Awaitable from os import PathLike +from typing import Awaitable, Callable, List, Tuple + +import discord +from discord.commands import OptionChoice +from discord.ext import commands from cogs.music import audio_playback, media_processing from cogs.utils import file_search as search_utils -from discord.ext import commands -from discord.commands import OptionChoice -import logging logger = logging.getLogger(__name__) + class PlayCommands(commands.Cog): """Discord slash commands for music playback.""" - + def __init__(self, bot): self.bot = bot - self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") + self.temp_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp" + ) self.path_mapping = {} # Maps display names to full paths @staticmethod - async def _create_search_embed(candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: + async def _create_search_embed( + candidates: List[str], + ) -> Tuple[discord.Embed, discord.ui.View]: """Create an embed and view for search results.""" embed = discord.Embed( title="Wyniki wyszukiwania", description="Wybierz plik do odtworzenia:", - color=discord.Color.blue() + color=discord.Color.blue(), ) - + view = discord.ui.View(disable_on_timeout=True) - + for i, candidate in enumerate(candidates, start=1): - filename = candidate.split('/')[-1].split('?')[0] + filename = candidate.split("/")[-1].split("?")[0] display_name = os.path.splitext(filename)[0] embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) - + return embed, view async def _handle_search_results( self, ctx: discord.ApplicationContext, candidates: List[str], - play_callback: Callable[[discord.Interaction, str], Awaitable[None]] + play_callback: Callable[[discord.Interaction, str], Awaitable[None]], ) -> None: """Handle the display of search results and button interactions.""" if not candidates: await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) return - + embed, view = await self._create_search_embed(candidates) - + # Add buttons for each candidate for i, candidate in enumerate(candidates, start=1): button = discord.ui.Button(label=f"Play {i}") - - async def button_callback(interaction: discord.Interaction, candidate_path=candidate): + + async def button_callback( + interaction: discord.Interaction, candidate_path=candidate + ): await interaction.response.defer(ephemeral=True) await ctx.interaction.delete_original_response() try: await play_callback(interaction, candidate_path) except Exception as e: logger.error(f"Error in play callback: {str(e)}", exc_info=True) - await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) - + await interaction.followup.send( + f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True + ) + button.callback = button_callback view.add_item(button) - + await ctx.respond(embed=embed, view=view, ephemeral=True) - async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: + async def _search_autocomplete( + self, ctx: discord.AutocompleteContext + ) -> List[OptionChoice]: """Autocomplete handler for search queries.""" query = ctx.value.lower() try: - results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio(query, max_results=25) + results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( + query, max_results=25 + ) choices = [] self.path_mapping = {} # Reset the mapping for each new query for url in results: - filename = url.split('/')[-1] - filename = filename.split('?')[0] # Remove query params if any + filename = url.split("/")[-1] + filename = filename.split("?")[0] # Remove query params if any filename = urllib.parse.unquote(filename) # Decode URL encoding display = os.path.splitext(filename)[0] # Remove extension @@ -93,7 +107,7 @@ class PlayCommands(commands.Cog): self.path_mapping[display] = url if len(display) > 90: - display = display[:87] + '...' + display = display[:87] + "..." choices.append(OptionChoice(display[:100], display[:100])) @@ -106,25 +120,12 @@ class PlayCommands(commands.Cog): logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) return [] - async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: - """Validate input parameters for play command.""" - if not file and not stream: - await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) - return False - - if file and stream: - await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) - return False - - return True - - async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None: - """Handle internet stream playback.""" - await audio_playback.AudioPlaybackManager.play(ctx, stream) - - async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None: + async def _handle_compressed_file_play( + self, ctx: discord.ApplicationContext, url: str + ) -> None: """Download from copyparty, compress, then play locally.""" import aiohttp + local_path = None compressed_path = None try: @@ -133,21 +134,25 @@ class PlayCommands(commands.Cog): if response.status != 200: await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) return - - filename = url.split('/')[-1].split('?')[0] + + filename = url.split("/")[-1].split("?")[0] filename = urllib.parse.unquote(filename) local_path = os.path.join(self.temp_dir, filename) os.makedirs(self.temp_dir, exist_ok=True) - - with open(local_path, 'wb') as f: + + with open(local_path, "wb") as f: async for chunk in response.content.iter_chunked(8192): f.write(chunk) - - compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) + + compressed_path = await media_processing.AudioCompressor.compress( + ctx, local_path + ) if not compressed_path: return - - await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) + + await audio_playback.AudioPlaybackManager.play( + ctx, compressed_path, delete_after_play=True + ) except Exception as e: logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) @@ -158,10 +163,12 @@ class PlayCommands(commands.Cog): except Exception: pass - async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: + async def _handle_local_file_play( + self, ctx: discord.ApplicationContext, file: str, compressed: bool + ) -> None: """Handle file playback from copyparty URL.""" url = self.path_mapping.get(file, file) - + if compressed: await self._handle_compressed_file_play(ctx, url) else: @@ -169,46 +176,47 @@ class PlayCommands(commands.Cog): @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") async def play( - self, - ctx: discord.ApplicationContext, - file: str = discord.Option( - str, - "wyszukaj plik z serwera", - autocomplete=_search_autocomplete, - required=False - ), - stream: str = discord.Option( - str, - "odtwórz stream internetowy (e.g. radio)", - required=False - ), - compressed: bool = discord.Option( - bool, - "Czy skompresować dźwięk?", - default=False - ) + self, + ctx: discord.ApplicationContext, + melodia: str = discord.Option( + str, + "odtwórz plik z serwera bądź strumienia", + autocomplete=_search_autocomplete, + required=False, + ), + file: discord.Attachment = discord.Option( + discord.Attachment, "plik do zagrania", required=False + ), + compressed: bool = discord.Option( + bool, "Czy skompresować dźwięk?", default=False + ), ): """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" await ctx.defer() - - if not await self._validate_play_inputs(file, stream, ctx): + + if melodia and file: + await ctx.respond( + "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True + ) return - - try: - if stream: - await self._handle_stream_play(ctx, stream) - else: - await self._handle_local_file_play(ctx, file, compressed) - except Exception as e: - logger.error(f"Error playing: {str(e)}", exc_info=True) - await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) - @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") - async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): - await ctx.defer() - file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) - await attachment.save(file_path) - await audio_playback.AudioPlaybackManager.play(ctx, file_path) + if not melodia and not file: + await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) + return + + if file: + file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) + await file.save(file_path) + await audio_playback.AudioPlaybackManager.play(ctx, file_path) + else: + try: + await self._handle_local_file_play(ctx, melodia, compressed) + except Exception as e: + logger.error(f"Error playing: {str(e)}", exc_info=True) + await ctx.respond( + f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True + ) + def setup(bot): bot.add_cog(PlayCommands(bot)) -- cgit v1.2.3