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 ++++++++++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 31 deletions(-) (limited to 'cogs/music/audio_playback.py') 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 + ) -- cgit v1.2.3