aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/play_commands.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
committersalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
commitcd8ed8bc5793985df2c1692072a932ceee517b95 (patch)
tree42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /cogs/music/play_commands.py
parentMerge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff)
downloadsenator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz
senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
-rw-r--r--cogs/music/play_commands.py320
1 files changed, 0 insertions, 320 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
deleted file mode 100644
index c6a55b0..0000000
--- a/cogs/music/play_commands.py
+++ /dev/null
@@ -1,320 +0,0 @@
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
6import logging
7import os
8import urllib.parse
9import asyncio
10from os import PathLike
11from typing import Awaitable, Callable, List, Tuple
12import re
13import requests
14from yt_dlp import YoutubeDL
15
16import discord
17from discord.commands import OptionChoice
18from discord.ext import commands
19
20from cogs.music import media_processing
21from cogs.utils.file_search import UniversalSearcher
22from cogs.utils.settings import get_uploadurl
23from cogs.utils.url_helper import clean_url_for_ytdlp
24from cogs.voice import get_voice_manager
25
26logger = logging.getLogger(__name__)
27
28EXTERNAL_URL_PATTERN = re.compile(
29 r"^https://(?=.*(?:youtube|youtu\.be|soundcloud|bandcamp))"
30)
31
32
33class PlayCommands(commands.Cog):
34 """Discord slash commands for music playback."""
35
36 def __init__(self, bot):
37 from cogs.utils.temp import get_temp_dir
38
39 self.bot = bot
40 self.temp_dir = get_temp_dir()
41 self.path_mapping = {} # Maps display names to full paths
42
43 @staticmethod
44 async def _create_search_embed(
45 candidates: List[str],
46 ) -> Tuple[discord.Embed, discord.ui.View]:
47 """Create an embed and view for search results."""
48 embed = discord.Embed(
49 title="Wyniki wyszukiwania",
50 description="Wybierz plik do odtworzenia:",
51 color=discord.Color.blue(),
52 )
53
54 view = discord.ui.View(disable_on_timeout=True)
55
56 for i, candidate in enumerate(candidates, start=1):
57 filename = candidate.split("/")[-1].split("?")[0]
58 display_name = os.path.splitext(filename)[0]
59 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False)
60
61 return embed, view
62
63 async def _handle_search_results(
64 self,
65 ctx: discord.ApplicationContext,
66 candidates: List[str],
67 play_callback: Callable[[discord.Interaction, str], Awaitable[None]],
68 ) -> None:
69 """Handle the display of search results and button interactions."""
70 if not candidates:
71 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True)
72 return
73
74 embed, view = await self._create_search_embed(candidates)
75
76 # Add buttons for each candidate
77 for i, candidate in enumerate(candidates, start=1):
78 button = discord.ui.Button(label=f"Play {i}")
79
80 async def button_callback(
81 interaction: discord.Interaction, candidate_path=candidate
82 ):
83 await interaction.response.defer(ephemeral=True)
84 await ctx.interaction.delete_original_response()
85 try:
86 await play_callback(interaction, candidate_path)
87 except Exception as e:
88 logger.error(f"Error in play callback: {str(e)}", exc_info=True)
89 await interaction.followup.send(
90 f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True
91 )
92
93 button.callback = button_callback
94 view.add_item(button)
95
96 await ctx.respond(embed=embed, view=view, ephemeral=True)
97
98 async def _search_autocomplete(
99 self, ctx: discord.AutocompleteContext
100 ) -> List[OptionChoice]:
101 """Autocomplete handler for search queries."""
102 if ctx.value.lower().startswith(("http://", "https://")):
103 return []
104
105 query = ctx.value.lower()
106 try:
107 results = await UniversalSearcher.search_copyparty_autocomplete_audio(
108 query, max_results=25
109 )
110 choices = []
111 self.path_mapping = {} # Reset the mapping for each new query
112
113 for url in results:
114 filename = url.split("/")[-1]
115 filename = filename.split("?")[0] # Remove query params if any
116 filename = urllib.parse.unquote(filename) # Decode URL encoding
117 display = os.path.splitext(filename)[0] # Remove extension
118
119 display = display.strip()
120
121 if len(display) > 100:
122 display = display[:99] + "…"
123
124 self.path_mapping[display] = url
125 choices.append(OptionChoice(display, display))
126
127 if len(choices) >= 25:
128 break
129
130 return choices
131
132 except Exception as e:
133 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True)
134 return []
135
136 async def _handle_compressed_file_play(
137 self, ctx: discord.ApplicationContext, url: str
138 ) -> None:
139 """Download from copyparty, compress, then play locally."""
140 import aiohttp
141
142 local_path = None
143 compressed_path = None
144 try:
145 async with aiohttp.ClientSession() as session:
146 async with session.get(url) as response:
147 if response.status != 200:
148 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
149 return
150
151 filename = url.split("/")[-1].split("?")[0]
152 filename = urllib.parse.unquote(filename)
153 local_path = os.path.join(self.temp_dir, filename)
154 os.makedirs(self.temp_dir, exist_ok=True)
155
156 loop = asyncio.get_event_loop()
157 with open(local_path, "wb") as f:
158 async for chunk in response.content.iter_chunked(8192):
159 await loop.run_in_executor(None, f.write, chunk)
160
161 compressed_path = await media_processing.AudioCompressor.compress(
162 ctx, local_path
163 )
164 if not compressed_path:
165 return
166
167 await get_voice_manager(self.bot).play(
168 ctx, compressed_path, delete_after_play=True
169 )
170 except Exception as e:
171 logger.error(f"Error in compressed playback: {str(e)}", exc_info=True)
172 await ctx.respond(f"Błąd: {str(e)}", ephemeral=True)
173 finally:
174 if local_path and os.path.exists(local_path):
175 try:
176 os.remove(local_path)
177 except Exception:
178 pass
179
180 async def _handle_external_url_play(
181 self, ctx: discord.ApplicationContext, url: str
182 ) -> None:
183 """Download from external URL, upload to copyparty, then play."""
184 local_path = None
185 try:
186 ytdl_opts = {
187 "outtmpl": os.path.join(self.temp_dir, "%(title)s.%(ext)s"),
188 "postprocessors": [
189 {
190 "key": "FFmpegExtractAudio",
191 "preferredcodec": "mp3",
192 "preferredquality": "192",
193 }
194 ],
195 "force_ipv4": True,
196 }
197
198 loop = asyncio.get_event_loop()
199 url = clean_url_for_ytdlp(url)
200 with YoutubeDL(ytdl_opts) as ydl:
201 info_dict = await loop.run_in_executor(
202 None, lambda: ydl.extract_info(url, download=True)
203 )
204 title = info_dict.get("title", "nieznany")
205 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
206 local_path = f"{base_filename}.mp3"
207
208 if not os.path.exists(local_path):
209 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
210 return
211
212 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path))
213
214 await get_voice_manager(self.bot).play(
215 ctx, local_path, delete_after_play=True
216 )
217
218 try:
219 await asyncio.wait_for(upload_task, timeout=30.0)
220 except asyncio.TimeoutError:
221 logger.warning("Upload timed out")
222 except Exception as e:
223 logger.error(f"Error downloading external URL: {str(e)}", exc_info=True)
224 if local_path and os.path.exists(local_path):
225 try:
226 os.remove(local_path)
227 except Exception:
228 pass
229 await ctx.respond(f"Błąd pobierania: {str(e)}", ephemeral=True)
230
231 async def _upload_to_copyparty_async(self, file_path: str) -> None:
232 """Upload file to copyparty asynchronously (fire and forget)."""
233 try:
234 logger.info(f"Starting async upload: {file_path}")
235 baseurl = get_uploadurl()
236 password = os.getenv("COPYPARTY_PASSWORD", "")
237
238 logger.info(f"Upload - baseurl: {baseurl}, password set: {bool(password)}")
239
240 if baseurl == "http://localhost:3939":
241 logger.warning("COPYPARTY_URL_UPLOAD not set! Using default")
242
243 with open(file_path, "rb") as f:
244 content = f.read()
245
246 files = {"f": (os.path.basename(file_path), content, "application/octet-stream")}
247 headers = {"Cookie": f"cppwd={password}"}
248 params = {"j": "1", "ck": "b2"}
249
250 logger.info(f"Posting to {baseurl}")
251 response = requests.post(baseurl, files=files, headers=headers, params=params)
252 text = response.text
253 logger.info(f"Upload response: {response.status_code} - {text[:200] if text else 'empty'}")
254
255 if response.status_code in [200, 201]:
256 result = response.json()
257 logger.info(f"Uploaded to copyparty: {result.get('fileurl')}")
258 except Exception as e:
259 logger.error(f"Async upload failed: {e}", exc_info=True)
260
261 async def _handle_local_file_play(
262 self, ctx: discord.ApplicationContext, file: str, compressed: bool
263 ) -> None:
264 """Handle file playback from copyparty URL."""
265 url = self.path_mapping.get(file, file) # Look up URL from display name
266
267 if compressed:
268 await self._handle_compressed_file_play(ctx, url)
269 else:
270 await get_voice_manager(self.bot).play(ctx, url)
271
272 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
273 async def play(
274 self,
275 ctx: discord.ApplicationContext,
276 melodia: str = discord.Option(
277 str,
278 "odtwórz plik z serwera bądź strumienia",
279 autocomplete=_search_autocomplete,
280 required=False,
281 ),
282 file: discord.Attachment = discord.Option(
283 discord.Attachment, "plik do zagrania", required=False
284 ),
285 compressed: bool = discord.Option(
286 bool, "Czy skompresować dźwięk?", default=False
287 ),
288 ):
289 """Odtwarza wybrany plik dźwiękowy lub stream internetowy."""
290 await ctx.defer()
291
292 if melodia and file:
293 await ctx.respond(
294 "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True
295 )
296 return
297
298 if not melodia and not file:
299 await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True)
300 return
301
302 if file:
303 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename)
304 await file.save(file_path)
305 await get_voice_manager(self.bot).play(ctx, file_path)
306 else:
307 try:
308 if EXTERNAL_URL_PATTERN.match(melodia.lower()):
309 await self._handle_external_url_play(ctx, melodia)
310 else:
311 await self._handle_local_file_play(ctx, melodia, compressed)
312 except Exception as e:
313 logger.error(f"Error playing: {str(e)}", exc_info=True)
314 await ctx.respond(
315 f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True
316 )
317
318
319def setup(bot):
320 bot.add_cog(PlayCommands(bot))