aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-04-19 18:10:09 +0200
commitf8e1cf36e1efa0602bf2e450835f2a67654762a6 (patch)
treefe6b12226d7eebe2a0dc6525de3414d0c3718cc8 /cogs/music
parentadd support for x^2+y^2=0,4 and alike (diff)
parentfix: proper aiohttp async context manager mocking in test (diff)
downloadsenator-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.py55
-rw-r--r--cogs/music/play_commands.py112
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 @@
6import logging 6import logging
7import os 7import os
8import urllib.parse 8import urllib.parse
9import asyncio
9from os import PathLike 10from os import PathLike
10from typing import Awaitable, Callable, List, Tuple 11from typing import Awaitable, Callable, List, Tuple
12import re
13from yt_dlp import YoutubeDL
11 14
12import discord 15import discord
13from discord.commands import OptionChoice 16from discord.commands import OptionChoice
14from discord.ext import commands 17from discord.ext import commands
15 18
16from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
17from cogs.utils import file_search as search_utils 20from cogs.utils.file_search import UniversalSearcher
18 21
19logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
20 23
24EXTERNAL_URL_PATTERN = re.compile(
25 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
26)
27
21 28
22class PlayCommands(commands.Cog): 29class 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(