aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music/play_commands.py
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-02-04 16:16:26 +0100
committersalmonik <raisersoft@proton.me>2026-02-04 16:16:26 +0100
commitce61bf6bd5949046d15f58609f572fedcb8a073e (patch)
treecafaa12f11a06337bda5de08881e395ac66c1c11 /cogs/music/play_commands.py
parentbetter reply context (diff)
downloadsenator-sperma-ce61bf6bd5949046d15f58609f572fedcb8a073e.tar.gz
senator-sperma-ce61bf6bd5949046d15f58609f572fedcb8a073e.zip
refactor music
Diffstat (limited to 'cogs/music/play_commands.py')
-rw-r--r--cogs/music/play_commands.py236
1 files changed, 236 insertions, 0 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
new file mode 100644
index 0000000..b25ce84
--- /dev/null
+++ b/cogs/music/play_commands.py
@@ -0,0 +1,236 @@
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 os
7import discord
8import mutagen
9from typing import List, Tuple, Callable, Awaitable
10from os import PathLike
11
12from cogs.music import search_utils, audio_playback, media_processing
13from discord.ext import commands
14from discord.commands import OptionChoice
15import logging
16
17logger = logging.getLogger(__name__)
18
19class PlayCommands(commands.Cog):
20 """Discord slash commands for music playback."""
21
22 def __init__(self, bot):
23 self.bot = bot
24 self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp")
25 self.path_mapping = {} # Maps display names to full paths
26
27 @staticmethod
28 async def _create_search_embed(candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]:
29 """Create an embed and view for search results."""
30 embed = discord.Embed(
31 title="Wyniki wyszukiwania",
32 description="Wybierz plik do odtworzenia:",
33 color=discord.Color.blue()
34 )
35
36 view = discord.ui.View(disable_on_timeout=True)
37
38 for i, candidate in enumerate(candidates, start=1):
39 filename = os.path.basename(candidate)
40 title = None
41
42 # Try to get metadata if it's an audio file
43 if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']:
44 try:
45 metadata = mutagen.File(candidate)
46 if metadata:
47 title = metadata.get('title', None)
48 except Exception as e:
49 logger.debug(f"Could not read metadata for {filename}: {str(e)}")
50
51 # Add field with title if available, otherwise use filename
52 display_name = title if title else filename
53 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False)
54
55 return embed, view
56
57 async def _handle_search_results(
58 self,
59 ctx: discord.ApplicationContext,
60 candidates: List[str],
61 play_callback: Callable[[discord.Interaction, str], Awaitable[None]]
62 ) -> None:
63 """Handle the display of search results and button interactions."""
64 if not candidates:
65 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True)
66 return
67
68 embed, view = await self._create_search_embed(candidates)
69
70 # Add buttons for each candidate
71 for i, candidate in enumerate(candidates, start=1):
72 button = discord.ui.Button(label=f"Play {i}")
73
74 async def button_callback(interaction: discord.Interaction, candidate_path=candidate):
75 await interaction.response.defer(ephemeral=True)
76 await ctx.interaction.delete_original_response()
77 try:
78 await play_callback(interaction, candidate_path)
79 except Exception as e:
80 logger.error(f"Error in play callback: {str(e)}", exc_info=True)
81 await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True)
82
83 button.callback = button_callback
84 view.add_item(button)
85
86 await ctx.respond(embed=embed, view=view, ephemeral=True)
87
88 def _get_display_name_from_metadata(self, path: str, filename: str) -> str:
89 """Extract display name from audio file metadata."""
90 try:
91 if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']:
92 metadata = mutagen.File(path, easy=True)
93
94 title = metadata.get('title', [filename])[0]
95 artist = metadata.get('artist', [''])[0]
96 album_artist = metadata.get('albumartist', [artist])[0]
97
98 if album_artist and title:
99 return f"{album_artist} - {title}"
100 elif artist and title:
101 return f"{artist} - {title}"
102 elif title:
103 return title
104 except Exception as e:
105 logger.debug(f"Could not read metadata for {filename}: {str(e)}")
106
107 return filename
108
109 async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]:
110 """Autocomplete handler for search queries."""
111 query = ctx.value.lower()
112 try:
113 results = await search_utils.AudioSearcher.search_local_autocomplete(query, max_results=25)
114 choices = []
115 self.path_mapping = {} # Reset the mapping for each new query
116
117 for path in results:
118 filename = os.path.splitext(os.path.basename(path))[0] # Remove extension
119 display = self._get_display_name_from_metadata(path, filename)
120
121 # Clean up the display name (remove any extra whitespace or brackets)
122 display = display.strip()
123
124 # Store the mapping (use the cleaned display name as the key)
125 self.path_mapping[display] = path
126
127 # Truncate display name if too long
128 if len(display) > 90: # Leave room for potential numbering
129 display = display[:87] + '...'
130
131 choices.append(OptionChoice(display[:100], display[:100]))
132
133 # Limit to 25 choices (Discord's limit)
134 if len(choices) >= 25:
135 break
136
137 return choices
138
139 except Exception as e:
140 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True)
141 return []
142
143 def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool:
144 """Validate input parameters for play command."""
145 if not file and not stream:
146 ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True)
147 return False
148
149 if file and stream:
150 ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True)
151 return False
152
153 return True
154
155 async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None:
156 """Handle internet stream playback."""
157 await audio_playback.AudioPlaybackManager.play(ctx, stream)
158
159 async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, full_path: str) -> None:
160 """Handle compressed file playback with cleanup."""
161 compressed_path = await media_processing.AudioCompressor.compress(ctx, full_path)
162 if not compressed_path:
163 await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True)
164 return
165
166 try:
167 await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True)
168 except Exception as e:
169 # Clean up the compressed file if play fails
170 if os.path.exists(compressed_path):
171 try:
172 os.remove(compressed_path)
173 logger.info(f"Cleaned up temporary file after play error: {compressed_path}")
174 except Exception as cleanup_error:
175 logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}")
176 raise e
177
178 async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None:
179 """Handle local file playback."""
180 full_path = self.path_mapping.get(file, file)
181
182 if not os.path.exists(full_path):
183 logger.warning(f"File not found: {full_path} (display name: {file})")
184 await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True)
185 return
186
187 if compressed:
188 await self._handle_compressed_file_play(ctx, full_path)
189 else:
190 await audio_playback.AudioPlaybackManager.play(ctx, full_path)
191
192 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
193 async def play(
194 self,
195 ctx: discord.ApplicationContext,
196 file: str = discord.Option(
197 str,
198 "wyszukaj plik z serwera",
199 autocomplete=_search_autocomplete,
200 required=False
201 ),
202 stream: str = discord.Option(
203 str,
204 "odtwórz stream internetowy (e.g. radio)",
205 required=False
206 ),
207 compressed: bool = discord.Option(
208 bool,
209 "Czy skompresować dźwięk? (tylko dla plików lokalnych)",
210 default=False
211 )
212 ):
213 """Odtwarza wybrany plik dźwiękowy lub stream internetowy."""
214 await ctx.defer()
215
216 if not self._validate_play_inputs(file, stream, ctx):
217 return
218
219 try:
220 if stream:
221 await self._handle_stream_play(ctx, stream)
222 else:
223 await self._handle_local_file_play(ctx, file, compressed)
224 except Exception as e:
225 logger.error(f"Error playing: {str(e)}", exc_info=True)
226 await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True)
227
228 @discord.slash_command(name="grajcuj_plik", description="grajcuj plik")
229 async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)):
230 await ctx.defer()
231 file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename)
232 await attachment.save(file_path)
233 await audio_playback.AudioPlaybackManager.play(ctx, file_path)
234
235def setup(bot):
236 bot.add_cog(PlayCommands(bot))