aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/play_commands.py
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/play_commands.py
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 'cogs/music/play_commands.py')
-rw-r--r--cogs/music/play_commands.py112
1 files changed, 108 insertions, 4 deletions
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(