aboutsummaryrefslogtreecommitdiffstats
path: root/cogs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-04-19 17:41:05 +0200
committersalmonik <raisersoft@proton.me>2026-04-19 17:41:05 +0200
commit920d4b18e253f5642b895a1205d9826d54570db8 (patch)
tree8539d6e633bee7e2a2f74e5bd725d6a49e7268d1 /cogs
parentreadd main.py (diff)
downloadsenator-sperma-920d4b18e253f5642b895a1205d9826d54570db8.tar.gz
senator-sperma-920d4b18e253f5642b895a1205d9826d54570db8.zip
feat: add external URL playback support, fix vclogs audio handling
Diffstat (limited to '')
-rw-r--r--cogs/music/play_commands.py108
-rw-r--r--cogs/vclogs.py14
2 files changed, 116 insertions, 6 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index a82fa9d..5373436 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -9,16 +9,22 @@ import urllib.parse
9import asyncio 9import asyncio
10from os import PathLike 10from os import PathLike
11from typing import Awaitable, Callable, List, Tuple 11from typing import Awaitable, Callable, List, Tuple
12import re
13from yt_dlp import YoutubeDL
12 14
13import discord 15import discord
14from discord.commands import OptionChoice 16from discord.commands import OptionChoice
15from discord.ext import commands 17from discord.ext import commands
16 18
17from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
18from cogs.utils import file_search as search_utils 20from cogs.utils.file_search import UniversalSearcher
19 21
20logger = logging.getLogger(__name__) 22logger = logging.getLogger(__name__)
21 23
24EXTERNAL_URL_PATTERN = re.compile(
25 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
26)
27
22 28
23class PlayCommands(commands.Cog): 29class PlayCommands(commands.Cog):
24 """Discord slash commands for music playback.""" 30 """Discord slash commands for music playback."""
@@ -89,9 +95,12 @@ class PlayCommands(commands.Cog):
89 self, ctx: discord.AutocompleteContext 95 self, ctx: discord.AutocompleteContext
90 ) -> List[OptionChoice]: 96 ) -> List[OptionChoice]:
91 """Autocomplete handler for search queries.""" 97 """Autocomplete handler for search queries."""
98 if ctx.value.lower().startswith(("http://", "https://")):
99 return []
100
92 query = ctx.value.lower() 101 query = ctx.value.lower()
93 try: 102 try:
94 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( 103 results = await UniversalSearcher.search_copyparty_autocomplete_audio(
95 query, max_results=25 104 query, max_results=25
96 ) 105 )
97 choices = [] 106 choices = []
@@ -164,6 +173,96 @@ class PlayCommands(commands.Cog):
164 except Exception: 173 except Exception:
165 pass 174 pass
166 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
167 async def _handle_local_file_play( 266 async def _handle_local_file_play(
168 self, ctx: discord.ApplicationContext, file: str, compressed: bool 267 self, ctx: discord.ApplicationContext, file: str, compressed: bool
169 ) -> None: 268 ) -> None:
@@ -211,7 +310,10 @@ class PlayCommands(commands.Cog):
211 await audio_playback.AudioPlaybackManager.play(ctx, file_path) 310 await audio_playback.AudioPlaybackManager.play(ctx, file_path)
212 else: 311 else:
213 try: 312 try:
214 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)
215 except Exception as e: 317 except Exception as e:
216 logger.error(f"Error playing: {str(e)}", exc_info=True) 318 logger.error(f"Error playing: {str(e)}", exc_info=True)
217 await ctx.respond( 319 await ctx.respond(
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
index 85d19bc..fb0a2e0 100644
--- a/cogs/vclogs.py
+++ b/cogs/vclogs.py
@@ -102,12 +102,20 @@ class VcLogs(commands.Cog):
102 volume = get_volume() 102 volume = get_volume()
103 logger.info(f"connected: {voice_client}") 103 logger.info(f"connected: {voice_client}")
104 104
105 if voice_client and not is_playing: 105 if voice_client:
106 if is_playing:
107 logger.info("voice already playing, stopping current audio")
108 voice_client.stop()
109 await asyncio.sleep(0.1)
110
106 logger.info("preparing to play audio") 111 logger.info("preparing to play audio")
107 audio_source = discord.FFmpegPCMAudio(tts_file, options=f'-filter:a "volume={volume}"') 112 audio_source = discord.FFmpegPCMAudio(tts_file)
113 if volume != 1.0:
114 audio_source = discord.PCMVolumeTransformer(audio_source)
115 audio_source.volume = volume
108 voice_client.play(audio_source) 116 voice_client.play(audio_source)
109 while voice_client.is_playing(): 117 while voice_client.is_playing():
110 await asyncio.sleep(0.5) 118 await asyncio.sleep(0.1)
111 except Exception as e: 119 except Exception as e:
112 logger.error(f"TTS playback error: {e}") 120 logger.error(f"TTS playback error: {e}")
113 finally: 121 finally: