diff options
Diffstat (limited to '')
| -rw-r--r-- | cogs/music/audio_playback.py | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py new file mode 100644 index 0000000..3d4f453 --- /dev/null +++ b/cogs/music/audio_playback.py | |||
| @@ -0,0 +1,179 @@ | |||
| 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 os | ||
| 7 | import logging | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import discord | ||
| 11 | import mutagen | ||
| 12 | |||
| 13 | logger = logging.getLogger(__name__) | ||
| 14 | |||
| 15 | class AudioPlaybackManager: | ||
| 16 | """Handles audio playback, voice connections, and source preparation.""" | ||
| 17 | |||
| 18 | @staticmethod | ||
| 19 | async def cleanup_temp_file(source): | ||
| 20 | """Clean up temporary file if it exists.""" | ||
| 21 | if source and os.path.exists(source): | ||
| 22 | try: | ||
| 23 | os.remove(source) | ||
| 24 | logger.info(f"Cleaned up temporary file: {source}") | ||
| 25 | except Exception as e: | ||
| 26 | logger.error(f"Error cleaning up file {source}: {str(e)}") | ||
| 27 | |||
| 28 | @staticmethod | ||
| 29 | async def validate_voice_channel(ctx): | ||
| 30 | """Validate user is in a voice channel. Returns channel or None.""" | ||
| 31 | if not ctx.author.voice or not ctx.author.voice.channel: | ||
| 32 | await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) | ||
| 33 | return None | ||
| 34 | return ctx.author.voice.channel | ||
| 35 | |||
| 36 | @staticmethod | ||
| 37 | async def prepare_file_source(source): | ||
| 38 | """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" | ||
| 39 | logger.info(f"Attempting to play local file: {source}") | ||
| 40 | |||
| 41 | # Get absolute path and verify file exists | ||
| 42 | if not os.path.isabs(source): | ||
| 43 | source = os.path.abspath(source) | ||
| 44 | logger.debug(f"Converted to absolute path: {source}") | ||
| 45 | |||
| 46 | if not os.path.exists(source): | ||
| 47 | logger.error(f"File not found: {source}") | ||
| 48 | return None, None | ||
| 49 | |||
| 50 | filename = os.path.basename(source) | ||
| 51 | display_name = os.path.splitext(filename)[0] | ||
| 52 | return display_name, source | ||
| 53 | |||
| 54 | @staticmethod | ||
| 55 | async def connect_to_voice_channel(ctx, vc, target_channel): | ||
| 56 | """Connect or move to voice channel. Returns voice client.""" | ||
| 57 | try: | ||
| 58 | # Connect to voice channel if not connected | ||
| 59 | if vc is None: | ||
| 60 | logger.info(f"Connecting to voice channel: {target_channel.name}") | ||
| 61 | vc = await target_channel.connect() | ||
| 62 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 63 | # Move to user's channel if in a different one | ||
| 64 | elif vc.channel != target_channel: | ||
| 65 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 66 | await vc.move_to(target_channel) | ||
| 67 | logger.info(f"Moved to voice channel: {target_channel.name}") | ||
| 68 | return vc | ||
| 69 | except Exception as e: | ||
| 70 | logger.error(f"Error connecting to voice channel: {str(e)}") | ||
| 71 | raise | ||
| 72 | |||
| 73 | @staticmethod | ||
| 74 | async def prepare_audio_source(source, is_url, display_name): | ||
| 75 | """Prepare audio source for playback. Returns (source, final_display_name).""" | ||
| 76 | if is_url: | ||
| 77 | # For URLs, use FFmpeg with the direct URL | ||
| 78 | ffmpeg_options = { | ||
| 79 | 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', | ||
| 80 | 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' | ||
| 81 | } | ||
| 82 | audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) | ||
| 83 | return audio_source, display_name | ||
| 84 | else: | ||
| 85 | # For local files, use default options | ||
| 86 | audio_source = discord.FFmpegPCMAudio(source=source) | ||
| 87 | |||
| 88 | # Try to get metadata for local files | ||
| 89 | try: | ||
| 90 | metadata = mutagen.File(source) | ||
| 91 | if metadata: | ||
| 92 | title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None | ||
| 93 | if title: | ||
| 94 | display_name = title | ||
| 95 | logger.debug(f"Read metadata - title: {title}") | ||
| 96 | except Exception as e: | ||
| 97 | logger.warning(f"Could not read metadata from {source}: {str(e)}") | ||
| 98 | |||
| 99 | return audio_source, display_name | ||
| 100 | |||
| 101 | @staticmethod | ||
| 102 | async def cleanup_if_needed(source, delete_after_play, is_url): | ||
| 103 | """Clean up temporary file if needed.""" | ||
| 104 | if delete_after_play and not is_url: | ||
| 105 | await AudioPlaybackManager.cleanup_temp_file(source) | ||
| 106 | |||
| 107 | @staticmethod | ||
| 108 | async def prepare_source(ctx, source): | ||
| 109 | """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 110 | is_url = source.startswith(('http://', 'https://')) | ||
| 111 | |||
| 112 | if is_url: | ||
| 113 | logger.info(f"Attempting to play stream from URL: {source}") | ||
| 114 | return source, source, is_url | ||
| 115 | |||
| 116 | display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source) | ||
| 117 | if not display_name: | ||
| 118 | return None, None, None | ||
| 119 | |||
| 120 | return absolute_source, display_name, is_url | ||
| 121 | |||
| 122 | @staticmethod | ||
| 123 | async def execute_playback(ctx, source, display_name, is_url): | ||
| 124 | """Execute the actual audio playback.""" | ||
| 125 | vc = ctx.voice_client | ||
| 126 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | ||
| 127 | if not target_channel: | ||
| 128 | return False | ||
| 129 | |||
| 130 | try: | ||
| 131 | vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel) | ||
| 132 | |||
| 133 | # Stop any currently playing audio | ||
| 134 | if vc.is_playing() or vc.is_paused(): | ||
| 135 | vc.stop() | ||
| 136 | |||
| 137 | # Prepare audio source | ||
| 138 | audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name) | ||
| 139 | |||
| 140 | # Play the audio | ||
| 141 | vc.play(audio_source) | ||
| 142 | logger.info(f"Playing: {final_display_name}") | ||
| 143 | await ctx.respond(f"grom: {final_display_name}") | ||
| 144 | return True | ||
| 145 | |||
| 146 | except Exception as e: | ||
| 147 | logger.error(f"Error in play: {str(e)}", exc_info=True) | ||
| 148 | await ctx.respond(f"eror: {str(e)}", ephemeral=True) | ||
| 149 | return False | ||
| 150 | |||
| 151 | @staticmethod | ||
| 152 | async def validate_and_prepare_source(ctx, source): | ||
| 153 | """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" | ||
| 154 | prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source) | ||
| 155 | if not prepared_source: | ||
| 156 | return None, None, None | ||
| 157 | return prepared_source, display_name, is_url | ||
| 158 | |||
| 159 | @staticmethod | ||
| 160 | async def play(ctx, source, delete_after_play=False): | ||
| 161 | """Play audio from a local file or URL. | ||
| 162 | |||
| 163 | Args: | ||
| 164 | ctx: Discord context | ||
| 165 | source: Either a local file path or a URL to stream from | ||
| 166 | delete_after_play: If True, delete the file after playing (only for local files) | ||
| 167 | """ | ||
| 168 | # Validate and prepare source | ||
| 169 | prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) | ||
| 170 | if not prepared_source: | ||
| 171 | await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False) | ||
| 172 | return | ||
| 173 | |||
| 174 | # Execute playback | ||
| 175 | success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url) | ||
| 176 | |||
| 177 | # Cleanup if needed | ||
| 178 | if not success: | ||
| 179 | await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) | ||
