aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--cogs/music/join.py29
-rw-r--r--cogs/music/loop.py40
-rw-r--r--cogs/music/media_processing.py135
-rw-r--r--cogs/music/play_commands.py320
-rw-r--r--cogs/music/stop.py29
5 files changed, 0 insertions, 553 deletions
diff --git a/cogs/music/join.py b/cogs/music/join.py
deleted file mode 100644
index d68ca79..0000000
--- a/cogs/music/join.py
+++ /dev/null
@@ -1,29 +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
7
8from discord.ext import commands
9from cogs.voice import get_voice_manager
10
11logger = logging.getLogger(__name__)
12
13
14class Join(commands.Cog):
15 def __init__(self, bot):
16 self.bot = bot
17
18 @commands.slash_command(name="join", description="dołącza do kanalu")
19 async def join(self, ctx):
20 voice_mgr = get_voice_manager(self.bot)
21 channel = ctx.author.voice.channel
22 await voice_mgr.connect(channel)
23 await ctx.respond(content="\u200b", ephemeral=True)
24 await ctx.interaction.delete_original_response()
25 logger.info("Bot connected to voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Join(bot))
diff --git a/cogs/music/loop.py b/cogs/music/loop.py
deleted file mode 100644
index 6e2c838..0000000
--- a/cogs/music/loop.py
+++ /dev/null
@@ -1,40 +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
7
8from discord.ext import commands
9
10logger = logging.getLogger(__name__)
11
12
13class Loop(commands.Cog):
14 def __init__(self, bot):
15 self.bot = bot
16 self.looping = {}
17
18 def is_looping(self, guild_id):
19 return self.looping.get(guild_id, False)
20
21 def set_looping(self, guild_id, value):
22 self.looping[guild_id] = value
23
24 @commands.slash_command(
25 name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji"
26 )
27 async def loop(self, ctx):
28 guild_id = ctx.guild.id
29 looping = not self.is_looping(guild_id)
30 self.set_looping(guild_id, looping)
31 await ctx.respond(
32 f"looping jest teraz {'włączony' if looping else 'wyłączony'}."
33 )
34 logger.info(
35 f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}"
36 )
37
38
39def setup(bot):
40 bot.add_cog(Loop(bot))
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py
deleted file mode 100644
index 91bd406..0000000
--- a/cogs/music/media_processing.py
+++ /dev/null
@@ -1,135 +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 asyncio
7import logging
8import os
9
10import yt_dlp
11from youtubesearchpython import VideosSearch
12from cogs.utils.url_helper import clean_url_for_ytdlp
13
14logger = logging.getLogger(__name__)
15
16
17class MediaDownloader:
18 """Handles downloading media from various sources."""
19
20 @staticmethod
21 async def download(ctx, url):
22 """Download audio from YouTube URL."""
23 from cogs.utils.temp import get_temp_dir
24
25 temp_dir = get_temp_dir("senator_sperma_music")
26
27 ytdl_opts = {
28 "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"),
29 "postprocessors": [
30 {
31 "key": "FFmpegExtractAudio",
32 "preferredcodec": "mp3",
33 "preferredquality": "192",
34 }
35 ],
36 "force_ipv4": True,
37 }
38
39 try:
40 url = clean_url_for_ytdlp(url)
41 with yt_dlp.YoutubeDL(ytdl_opts) as ydl:
42 info_dict = ydl.extract_info(url, download=True)
43 # Get the base filename without extension
44 base_filename = os.path.splitext(ydl.prepare_filename(info_dict))[0]
45 # Add .mp3 extension since we're extracting audio
46 filename = f"{base_filename}.mp3"
47 logger.info(f"Downloaded file: {filename}")
48 return filename
49 except Exception as e:
50 logger.error(f"Error downloading video: {e}")
51 await ctx.respond("eror pobirajync wideło", ephemeral=True)
52
53 @staticmethod
54 async def get_url(ctx, query):
55 """Search YouTube and get URL for a query."""
56 search = VideosSearch(query, limit=1)
57 if search.result()["result"]:
58 title = search.result()["result"][0]["title"]
59 url = search.result()["result"][0]["link"]
60 logger.info(f"Found video: {title}")
61 return url, title
62 else:
63 logger.warning("No results found for the query.")
64 await ctx.respond("nic ni ma", ephemeral=True)
65 return None
66
67
68class AudioCompressor:
69 """Handles audio compression using FFmpeg."""
70
71 @staticmethod
72 async def compress(ctx, filename):
73 """Compress audio file to opus format."""
74 from cogs.utils.temp import get_temp_dir
75
76 if not filename:
77 await ctx.respond("dzie wideło", ephemeral=True)
78 return None
79
80 temp_dir = get_temp_dir("senator_sperma_compressed")
81
82 # Create a temporary output filename in the temp directory
83 base_name = os.path.basename(filename)
84 output_filename = os.path.join(
85 temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus"
86 )
87
88 # Ensure the output directory exists
89 os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True)
90
91 # Use proper quoting for filenames with spaces
92 ffmpeg_command = [
93 "ffmpeg",
94 "-y", # Overwrite output file if it exists
95 "-i",
96 filename,
97 "-ac",
98 "1", # Mono audio
99 "-b:a",
100 "500", # 500k bitrate
101 "-acodec",
102 "libopus",
103 output_filename,
104 ]
105
106 try:
107 logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}")
108 process = await asyncio.create_subprocess_exec(
109 *ffmpeg_command,
110 stdout=asyncio.subprocess.PIPE,
111 stderr=asyncio.subprocess.PIPE,
112 )
113
114 _, stderr = await process.communicate()
115
116 if process.returncode != 0:
117 error_msg = stderr.decode()
118 logger.error(f"FFmpeg error ({process.returncode}): {error_msg}")
119 await ctx.respond("eror kompresujync pilk", ephemeral=True)
120 return None
121
122 if not os.path.exists(output_filename):
123 logger.error(f"Output file was not created: {output_filename}")
124 await ctx.respond(
125 "eror kompresujync pilk - output file not created", ephemeral=True
126 )
127 return None
128
129 logger.info(f"Successfully compressed file: {output_filename}")
130 return output_filename
131
132 except Exception as e:
133 logger.error(f"Error compressing file: {str(e)}", exc_info=True)
134 await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True)
135 return None
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))
diff --git a/cogs/music/stop.py b/cogs/music/stop.py
deleted file mode 100644
index 9c8d5cf..0000000
--- a/cogs/music/stop.py
+++ /dev/null
@@ -1,29 +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
6from discord.ext import commands
7
8from cogs.voice import get_voice_manager
9
10import logging
11
12logger = logging.getLogger(__name__)
13
14
15class Stop(commands.Cog):
16 def __init__(self, bot):
17 self.bot = bot
18
19 @commands.slash_command(name="stop", description="stopuj bota")
20 async def stop(self, ctx):
21 await ctx.respond(content="\u200b", ephemeral=True)
22 await ctx.interaction.delete_original_response()
23 voice_mgr = get_voice_manager(self.bot)
24 await voice_mgr.disconnect(ctx.guild)
25 logger.info("Bot disconnected from voice channel")
26
27
28def setup(bot):
29 bot.add_cog(Stop(bot))