diff options
| author | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
|---|---|---|
| committer | salmonik <salmonik@noreply.codeberg.org> | 2026-04-19 18:10:09 +0200 |
| commit | f8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch) | |
| tree | fe6b12226d7eebe2a0dc6525de3414d0c3718cc8 /cogs/music | |
| parent | add support for x^2+y^2=0,4 and alike (diff) | |
| parent | fix: proper aiohttp async context manager mocking in test (diff) | |
| download | senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.tar.gz senator-sperma-f8e1cf36e1efa0602bf2e450835f2a67654762a6.zip | |
Merge pull request 'massive changes' (#3) from dev into main
Reviewed-on: https://codeberg.org/salmonik/senator-sperma/pulls/3
stable enough
Diffstat (limited to '')
| -rw-r--r-- | cogs/music/audio_playback.py | 55 | ||||
| -rw-r--r-- | cogs/music/play_commands.py | 112 |
2 files changed, 146 insertions, 21 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py index 1267fa7..28c6118 100644 --- a/cogs/music/audio_playback.py +++ b/cogs/music/audio_playback.py | |||
| @@ -60,17 +60,18 @@ class AudioPlaybackManager: | |||
| 60 | """Connect or move to voice channel. Returns voice client.""" | 60 | """Connect or move to voice channel. Returns voice client.""" |
| 61 | try: | 61 | try: |
| 62 | # Check if vc is valid and actually connected | 62 | # Check if vc is valid and actually connected |
| 63 | is_connected = vc is not None and vc.is_connected() | 63 | if vc is not None and vc.is_connected(): |
| 64 | # Connect to voice channel if not connected | 64 | # Already connected - check if need to move |
| 65 | if not is_connected: | 65 | if vc.channel != target_channel: |
| 66 | logger.info(f"Connecting to voice channel: {target_channel.name}") | 66 | logger.info(f"Moving to voice channel: {target_channel.name}") |
| 67 | vc = await target_channel.connect() | 67 | await vc.move_to(target_channel) |
| 68 | logger.info(f"Connected to voice channel: {target_channel.name}") | 68 | logger.info(f"Moved to voice channel: {target_channel.name}") |
| 69 | # Move to user's channel if in a different one | 69 | return vc |
| 70 | elif vc.channel != target_channel: | 70 | |
| 71 | logger.info(f"Moving to voice channel: {target_channel.name}") | 71 | # Not connected - connect now |
| 72 | await vc.move_to(target_channel) | 72 | logger.info(f"Connecting to voice channel: {target_channel.name}") |
| 73 | logger.info(f"Moved to voice channel: {target_channel.name}") | 73 | vc = await target_channel.connect() |
| 74 | logger.info(f"Connected to voice channel: {target_channel.name}") | ||
| 74 | return vc | 75 | return vc |
| 75 | except Exception as e: | 76 | except Exception as e: |
| 76 | logger.error(f"Error connecting to voice channel: {str(e)}") | 77 | logger.error(f"Error connecting to voice channel: {str(e)}") |
| @@ -138,15 +139,35 @@ class AudioPlaybackManager: | |||
| 138 | @staticmethod | 139 | @staticmethod |
| 139 | async def execute_playback(ctx, source, display_name, is_url): | 140 | async def execute_playback(ctx, source, display_name, is_url): |
| 140 | """Execute the actual audio playback.""" | 141 | """Execute the actual audio playback.""" |
| 141 | vc = ctx.voice_client | ||
| 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) | 142 | target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) |
| 143 | if not target_channel: | 143 | if not target_channel: |
| 144 | return False | 144 | return False |
| 145 | 145 | ||
| 146 | # Get existing voice client from bot or connect | ||
| 147 | guild = ctx.guild | ||
| 148 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 149 | |||
| 146 | try: | 150 | try: |
| 147 | vc = await AudioPlaybackManager.connect_to_voice_channel( | 151 | # Handle various connection states |
| 148 | ctx, vc, target_channel | 152 | if vc is None: |
| 149 | ) | 153 | logger.info("No existing voice client, connecting...") |
| 154 | try: | ||
| 155 | vc = await target_channel.connect() | ||
| 156 | except discord.ClientException: | ||
| 157 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 158 | elif not vc.is_connected(): | ||
| 159 | logger.info("Voice client exists but disconnected, reconnecting...") | ||
| 160 | try: | ||
| 161 | await vc.disconnect() | ||
| 162 | except Exception: | ||
| 163 | pass | ||
| 164 | try: | ||
| 165 | vc = await target_channel.connect() | ||
| 166 | except discord.ClientException: | ||
| 167 | vc = discord.utils.get(ctx.bot.voice_clients, guild=guild) | ||
| 168 | elif vc.channel != target_channel: | ||
| 169 | logger.info(f"Moving to voice channel: {target_channel.name}") | ||
| 170 | await vc.move_to(target_channel) | ||
| 150 | 171 | ||
| 151 | # Stop any currently playing audio | 172 | # Stop any currently playing audio |
| 152 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] | 173 | if vc.is_playing() or vc.is_paused(): # type: ignore[union-attr] |
| @@ -171,7 +192,7 @@ class AudioPlaybackManager: | |||
| 171 | # Play the audio | 192 | # Play the audio |
| 172 | vc.play(audio_source, after=after_playing) | 193 | vc.play(audio_source, after=after_playing) |
| 173 | logger.info(f"Playing: {final_display_name}") | 194 | logger.info(f"Playing: {final_display_name}") |
| 174 | await ctx.respond(f"grom: {final_display_name}") | 195 | await ctx.followup.send(f"grom: {final_display_name}") |
| 175 | 196 | ||
| 176 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds | 197 | # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds |
| 177 | await asyncio.sleep(5) | 198 | await asyncio.sleep(5) |
| @@ -195,7 +216,7 @@ class AudioPlaybackManager: | |||
| 195 | 216 | ||
| 196 | except Exception as e: | 217 | except Exception as e: |
| 197 | logger.error(f"Error in play: {str(e)}", exc_info=True) | 218 | logger.error(f"Error in play: {str(e)}", exc_info=True) |
| 198 | await ctx.respond(f"eror: {str(e)}", ephemeral=True) | 219 | await ctx.followup.send(f"eror: {str(e)}", ephemeral=True) |
| 199 | return False | 220 | return False |
| 200 | 221 | ||
| 201 | @staticmethod | 222 | @staticmethod |
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index fd5113a..5373436 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -6,18 +6,25 @@ | |||
| 6 | import logging | 6 | import logging |
| 7 | import os | 7 | import os |
| 8 | import urllib.parse | 8 | import urllib.parse |
| 9 | import asyncio | ||
| 9 | from os import PathLike | 10 | from os import PathLike |
| 10 | from typing import Awaitable, Callable, List, Tuple | 11 | from typing import Awaitable, Callable, List, Tuple |
| 12 | import re | ||
| 13 | from yt_dlp import YoutubeDL | ||
| 11 | 14 | ||
| 12 | import discord | 15 | import discord |
| 13 | from discord.commands import OptionChoice | 16 | from discord.commands import OptionChoice |
| 14 | from discord.ext import commands | 17 | from discord.ext import commands |
| 15 | 18 | ||
| 16 | from cogs.music import audio_playback, media_processing | 19 | from cogs.music import audio_playback, media_processing |
| 17 | from cogs.utils import file_search as search_utils | 20 | from cogs.utils.file_search import UniversalSearcher |
| 18 | 21 | ||
| 19 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 20 | 23 | ||
| 24 | EXTERNAL_URL_PATTERN = re.compile( | ||
| 25 | r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))" | ||
| 26 | ) | ||
| 27 | |||
| 21 | 28 | ||
| 22 | class PlayCommands(commands.Cog): | 29 | class PlayCommands(commands.Cog): |
| 23 | """Discord slash commands for music playback.""" | 30 | """Discord slash commands for music playback.""" |
| @@ -88,9 +95,12 @@ class PlayCommands(commands.Cog): | |||
| 88 | self, ctx: discord.AutocompleteContext | 95 | self, ctx: discord.AutocompleteContext |
| 89 | ) -> List[OptionChoice]: | 96 | ) -> List[OptionChoice]: |
| 90 | """Autocomplete handler for search queries.""" | 97 | """Autocomplete handler for search queries.""" |
| 98 | if ctx.value.lower().startswith(("http://", "https://")): | ||
| 99 | return [] | ||
| 100 | |||
| 91 | query = ctx.value.lower() | 101 | query = ctx.value.lower() |
| 92 | try: | 102 | try: |
| 93 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( | 103 | results = await UniversalSearcher.search_copyparty_autocomplete_audio( |
| 94 | query, max_results=25 | 104 | query, max_results=25 |
| 95 | ) | 105 | ) |
| 96 | choices = [] | 106 | choices = [] |
| @@ -139,9 +149,10 @@ class PlayCommands(commands.Cog): | |||
| 139 | local_path = os.path.join(self.temp_dir, filename) | 149 | local_path = os.path.join(self.temp_dir, filename) |
| 140 | os.makedirs(self.temp_dir, exist_ok=True) | 150 | os.makedirs(self.temp_dir, exist_ok=True) |
| 141 | 151 | ||
| 152 | loop = asyncio.get_event_loop() | ||
| 142 | with open(local_path, "wb") as f: | 153 | with open(local_path, "wb") as f: |
| 143 | async for chunk in response.content.iter_chunked(8192): | 154 | async for chunk in response.content.iter_chunked(8192): |
| 144 | f.write(chunk) | 155 | await loop.run_in_executor(None, f.write, chunk) |
| 145 | 156 | ||
| 146 | compressed_path = await media_processing.AudioCompressor.compress( | 157 | compressed_path = await media_processing.AudioCompressor.compress( |
| 147 | ctx, local_path | 158 | ctx, local_path |
| @@ -162,6 +173,96 @@ class PlayCommands(commands.Cog): | |||
| 162 | except Exception: | 173 | except Exception: |
| 163 | pass | 174 | pass |
| 164 | 175 | ||
| 176 | async def _handle_external_url_play( | ||
| 177 | self, ctx: discord.ApplicationContext, url: str | ||
| 178 | ) -> None: | ||
| 179 | """Download from external URL, upload to copyparty, then play.""" | ||
| 180 | local_path = None | ||
| 181 | try: | ||
| 182 | ytdl_opts = { | ||
| 183 | "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"), | ||
| 184 | "postprocessors": [ | ||
| 185 | { | ||
| 186 | "key": "FFmpegExtractAudio", | ||
| 187 | "preferredcodec": "mp3", | ||
| 188 | "preferredquality": "192", | ||
| 189 | } | ||
| 190 | ], | ||
| 191 | "force_ipv4": True, | ||
| 192 | } | ||
| 193 | |||
| 194 | loop = asyncio.get_event_loop() | ||
| 195 | with YoutubeDL(ytdl_opts) as ydl: | ||
| 196 | info_dict = await loop.run_in_executor( | ||
| 197 | None, lambda: ydl.extract_info(url, download=True) | ||
| 198 | ) | ||
| 199 | title = info_dict.get("title", "nieznany") | ||
| 200 | base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0] | ||
| 201 | local_path = f"{base_filename}.mp3" | ||
| 202 | |||
| 203 | if not os.path.exists(local_path): | ||
| 204 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | ||
| 205 | return | ||
| 206 | |||
| 207 | upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) | ||
| 208 | |||
| 209 | await audio_playback.AudioPlaybackManager.play( | ||
| 210 | ctx, local_path, delete_after_play=True | ||
| 211 | ) | ||
| 212 | |||
| 213 | try: | ||
| 214 | await asyncio.wait_for(upload_task, timeout=30.0) | ||
| 215 | except asyncio.TimeoutError: | ||
| 216 | logger.warning("Upload timed out") | ||
| 217 | except Exception as e: | ||
| 218 | logger.error(f"Error downloading external URL: {str(e)}", exc_info=True) | ||
| 219 | if local_path and os.path.exists(local_path): | ||
| 220 | try: | ||
| 221 | os.remove(local_path) | ||
| 222 | except Exception: | ||
| 223 | pass | ||
| 224 | await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True) | ||
| 225 | |||
| 226 | async def _upload_to_copyparty_async(self, file_path: str) -> None: | ||
| 227 | """Upload file to copyparty asynchronously (fire and forget).""" | ||
| 228 | import aiohttp | ||
| 229 | |||
| 230 | try: | ||
| 231 | logger.info(f"Starting async upload: {file_path}") | ||
| 232 | baseurl = os.getenv("COPYPARTY_URL_UPLOAD") | ||
| 233 | password = os.getenv("COPYPARTY_PASSWORD", "") | ||
| 234 | |||
| 235 | logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}") | ||
| 236 | |||
| 237 | if not baseurl: | ||
| 238 | logger.error("COPYPARTY_URL_UPLOAD not set!") | ||
| 239 | return | ||
| 240 | |||
| 241 | with open(file_path, "rb") as f: | ||
| 242 | content = f.read() | ||
| 243 | |||
| 244 | files = aiohttp.FormData() | ||
| 245 | files.add_field( | ||
| 246 | "f", | ||
| 247 | content, | ||
| 248 | filename=os.path.basename(file_path), | ||
| 249 | content_type="application/octet-stream", | ||
| 250 | ) | ||
| 251 | headers = {"Cookie": f"cppwd={password}"} | ||
| 252 | params = {"j": "1", "ck": "b2"} | ||
| 253 | |||
| 254 | logger.info(f"Posting to {baseurl}") | ||
| 255 | async with aiohttp.ClientSession() as session: | ||
| 256 | async with session.post(baseurl, data=files, headers=headers, params=params) as response: | ||
| 257 | text = await response.text() | ||
| 258 | logger.info(f"Upload response: {response.status} - {text[:200] if text else 'empty'}") | ||
| 259 | |||
| 260 | if response.status in [200, 201]: | ||
| 261 | result = await response.json() | ||
| 262 | logger.info(f"Uploaded to copyparty: {result.get('fileurl')}") | ||
| 263 | except Exception as e: | ||
| 264 | logger.error(f"Async upload failed: {e}", exc_info=True) | ||
| 265 | |||
| 165 | async def _handle_local_file_play( | 266 | async def _handle_local_file_play( |
| 166 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | 267 | self, ctx: discord.ApplicationContext, file: str, compressed: bool |
| 167 | ) -> None: | 268 | ) -> None: |
| @@ -209,7 +310,10 @@ class PlayCommands(commands.Cog): | |||
| 209 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 310 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) |
| 210 | else: | 311 | else: |
| 211 | try: | 312 | try: |
| 212 | await self._handle_local_file_play(ctx, melodia, compressed) | 313 | if EXTERNAL_URL_PATTERN.match(melodia.lower()): |
| 314 | await self._handle_external_url_play(ctx, melodia) | ||
| 315 | else: | ||
| 316 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 213 | except Exception as e: | 317 | except Exception as e: |
| 214 | logger.error(f"Error playing: {str(e)}", exc_info=True) | 318 | logger.error(f"Error playing: {str(e)}", exc_info=True) |
| 215 | await ctx.respond( | 319 | await ctx.respond( |
