aboutsummaryrefslogtreecommitdiffstats
path: root/cogs/music
diff options
context:
space:
mode:
Diffstat (limited to 'cogs/music')
-rw-r--r--cogs/music/audio_playback.py121
-rw-r--r--cogs/music/join.py4
-rw-r--r--cogs/music/loop.py15
-rw-r--r--cogs/music/media_processing.py67
-rw-r--r--cogs/music/play_commands.py184
5 files changed, 239 insertions, 152 deletions
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
index 5632728..75608b8 100644
--- a/cogs/music/audio_playback.py
+++ b/cogs/music/audio_playback.py
@@ -3,17 +3,20 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import os 6import asyncio
7import logging 7import logging
8import os
8import urllib.parse 9import urllib.parse
10
9import discord 11import discord
10import mutagen 12import mutagen
11 13
12logger = logging.getLogger(__name__) 14logger = logging.getLogger(__name__)
13 15
16
14class AudioPlaybackManager: 17class AudioPlaybackManager:
15 """Handles audio playback, voice connections, and source preparation.""" 18 """Handles audio playback, voice connections, and source preparation."""
16 19
17 @staticmethod 20 @staticmethod
18 async def cleanup_temp_file(source): 21 async def cleanup_temp_file(source):
19 """Clean up temporary file if it exists.""" 22 """Clean up temporary file if it exists."""
@@ -28,7 +31,9 @@ class AudioPlaybackManager:
28 async def validate_voice_channel(ctx): 31 async def validate_voice_channel(ctx):
29 """Validate user is in a voice channel. Returns channel or None.""" 32 """Validate user is in a voice channel. Returns channel or None."""
30 if not ctx.author.voice or not ctx.author.voice.channel: 33 if not ctx.author.voice or not ctx.author.voice.channel:
31 await ctx.respond("Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True) 34 await ctx.respond(
35 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
36 )
32 return None 37 return None
33 return ctx.author.voice.channel 38 return ctx.author.voice.channel
34 39
@@ -36,16 +41,16 @@ class AudioPlaybackManager:
36 async def prepare_file_source(source): 41 async def prepare_file_source(source):
37 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None).""" 42 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None)."""
38 logger.info(f"Attempting to play local file: {source}") 43 logger.info(f"Attempting to play local file: {source}")
39 44
40 # Get absolute path and verify file exists 45 # Get absolute path and verify file exists
41 if not os.path.isabs(source): 46 if not os.path.isabs(source):
42 source = os.path.abspath(source) 47 source = os.path.abspath(source)
43 logger.debug(f"Converted to absolute path: {source}") 48 logger.debug(f"Converted to absolute path: {source}")
44 49
45 if not os.path.exists(source): 50 if not os.path.exists(source):
46 logger.error(f"File not found: {source}") 51 logger.error(f"File not found: {source}")
47 return None, None 52 return None, None
48 53
49 filename = os.path.basename(source) 54 filename = os.path.basename(source)
50 display_name = os.path.splitext(filename)[0] 55 display_name = os.path.splitext(filename)[0]
51 return display_name, source 56 return display_name, source
@@ -77,24 +82,28 @@ class AudioPlaybackManager:
77 if is_url: 82 if is_url:
78 # For URLs, use FFmpeg with the direct URL and extract clean filename 83 # For URLs, use FFmpeg with the direct URL and extract clean filename
79 ffmpeg_options = { 84 ffmpeg_options = {
80 'options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', 85 "options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
81 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5' 86 "before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
82 } 87 }
83 audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options) 88 audio_source = discord.FFmpegPCMAudio(source, **ffmpeg_options)
84 # Extract clean filename for URL (remove extension and decode URL encoding) 89 # Extract clean filename for URL (remove extension and decode URL encoding)
85 filename = os.path.basename(source.split('?')[0]) # Remove query parameters 90 filename = os.path.basename(source.split("?")[0]) # Remove query parameters
86 filename = urllib.parse.unquote(filename) # Decode URL encoding 91 filename = urllib.parse.unquote(filename) # Decode URL encoding
87 clean_filename = os.path.splitext(filename)[0] # Remove file extension 92 clean_filename = os.path.splitext(filename)[0] # Remove file extension
88 return audio_source, clean_filename 93 return audio_source, clean_filename
89 else: 94 else:
90 # For local files, use default options 95 # For local files, use default options
91 audio_source = discord.FFmpegPCMAudio(source=source) 96 audio_source = discord.FFmpegPCMAudio(source=source)
92 97
93 # Try to get metadata for local files 98 # Try to get metadata for local files
94 try: 99 try:
95 metadata = mutagen.File(source) 100 metadata = mutagen.File(source)
96 if metadata: 101 if metadata:
97 title = metadata.get('title', [None])[0] if hasattr(metadata, 'get') else None 102 title = (
103 metadata.get("title", [None])[0]
104 if hasattr(metadata, "get")
105 else None
106 )
98 if title: 107 if title:
99 display_name = title 108 display_name = title
100 logger.debug(f"Read metadata - title: {title}") 109 logger.debug(f"Read metadata - title: {title}")
@@ -103,7 +112,7 @@ class AudioPlaybackManager:
103 except Exception as e: 112 except Exception as e:
104 logger.warning(f"Could not read metadata from {source}: {str(e)}") 113 logger.warning(f"Could not read metadata from {source}: {str(e)}")
105 display_name = os.path.splitext(os.path.basename(source))[0] 114 display_name = os.path.splitext(os.path.basename(source))[0]
106 115
107 return audio_source, display_name 116 return audio_source, display_name
108 117
109 @staticmethod 118 @staticmethod
@@ -115,16 +124,18 @@ class AudioPlaybackManager:
115 @staticmethod 124 @staticmethod
116 async def prepare_source(ctx, source): 125 async def prepare_source(ctx, source):
117 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None).""" 126 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None)."""
118 is_url = source.startswith(('http://', 'https://')) 127 is_url = source.startswith(("http://", "https://"))
119 128
120 if is_url: 129 if is_url:
121 logger.info(f"Attempting to play stream from URL: {source}") 130 logger.info(f"Attempting to play stream from URL: {source}")
122 return source, source, is_url 131 return source, source, is_url
123 132
124 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(source) 133 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(
134 source
135 )
125 if not display_name: 136 if not display_name:
126 return None, None, None 137 return None, None, None
127 138
128 return absolute_source, display_name, is_url 139 return absolute_source, display_name, is_url
129 140
130 @staticmethod 141 @staticmethod
@@ -134,23 +145,57 @@ class AudioPlaybackManager:
134 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx) 145 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
135 if not target_channel: 146 if not target_channel:
136 return False 147 return False
137 148
138 try: 149 try:
139 vc = await AudioPlaybackManager.connect_to_voice_channel(ctx, vc, target_channel) 150 vc = await AudioPlaybackManager.connect_to_voice_channel(
140 151 ctx, vc, target_channel
152 )
153
141 # Stop any currently playing audio 154 # Stop any currently playing audio
142 if vc.is_playing() or vc.is_paused(): 155 if vc.is_playing() or vc.is_paused():
143 vc.stop() 156 vc.stop()
144 157
145 # Prepare audio source 158 # Prepare audio source
146 audio_source, final_display_name = await AudioPlaybackManager.prepare_audio_source(source, is_url, display_name) 159 (
160 audio_source,
161 final_display_name,
162 ) = await AudioPlaybackManager.prepare_audio_source(
163 source, is_url, display_name
164 )
165
166 # Track if playback failed
167 playback_failed = {"error": None}
168
169 def after_playing(error):
170 if error:
171 playback_failed["error"] = str(error)
172 logger.error(f"Playback error: {error}")
147 173
148 # Play the audio 174 # Play the audio
149 vc.play(audio_source) 175 vc.play(audio_source, after=after_playing)
150 logger.info(f"Playing: {final_display_name}") 176 logger.info(f"Playing: {final_display_name}")
151 await ctx.respond(f"grom: {final_display_name}") 177 await ctx.respond(f"grom: {final_display_name}")
178
179 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
180 await asyncio.sleep(5)
181
182 # Check if playback actually started
183 if playback_failed["error"]:
184 logger.error(f"Playback failed: {playback_failed['error']}")
185 await ctx.followup.send(
186 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
187 )
188 return False
189
190 if not vc.is_playing():
191 logger.error(f"Stream failed - not playing after 5s: {source}")
192 await ctx.followup.send(
193 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
194 )
195 return False
196
152 return True 197 return True
153 198
154 except Exception as e: 199 except Exception as e:
155 logger.error(f"Error in play: {str(e)}", exc_info=True) 200 logger.error(f"Error in play: {str(e)}", exc_info=True)
156 await ctx.respond(f"eror: {str(e)}", ephemeral=True) 201 await ctx.respond(f"eror: {str(e)}", ephemeral=True)
@@ -159,7 +204,11 @@ class AudioPlaybackManager:
159 @staticmethod 204 @staticmethod
160 async def validate_and_prepare_source(ctx, source): 205 async def validate_and_prepare_source(ctx, source):
161 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None).""" 206 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
162 prepared_source, display_name, is_url = await AudioPlaybackManager.prepare_source(ctx, source) 207 (
208 prepared_source,
209 display_name,
210 is_url,
211 ) = await AudioPlaybackManager.prepare_source(ctx, source)
163 if not prepared_source: 212 if not prepared_source:
164 return None, None, None 213 return None, None, None
165 return prepared_source, display_name, is_url 214 return prepared_source, display_name, is_url
@@ -167,21 +216,31 @@ class AudioPlaybackManager:
167 @staticmethod 216 @staticmethod
168 async def play(ctx, source, delete_after_play=False): 217 async def play(ctx, source, delete_after_play=False):
169 """Play audio from a local file or URL. 218 """Play audio from a local file or URL.
170 219
171 Args: 220 Args:
172 ctx: Discord context 221 ctx: Discord context
173 source: Either a local file path or a URL to stream from 222 source: Either a local file path or a URL to stream from
174 delete_after_play: If True, delete the file after playing (only for local files) 223 delete_after_play: If True, delete the file after playing (only for local files)
175 """ 224 """
176 # Validate and prepare source 225 # Validate and prepare source
177 prepared_source, display_name, is_url = await AudioPlaybackManager.validate_and_prepare_source(ctx, source) 226 (
227 prepared_source,
228 display_name,
229 is_url,
230 ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
178 if not prepared_source: 231 if not prepared_source:
179 await AudioPlaybackManager.cleanup_if_needed(source, delete_after_play, False) 232 await AudioPlaybackManager.cleanup_if_needed(
233 source, delete_after_play, False
234 )
180 return 235 return
181 236
182 # Execute playback 237 # Execute playback
183 success = await AudioPlaybackManager.execute_playback(ctx, prepared_source, display_name, is_url) 238 success = await AudioPlaybackManager.execute_playback(
184 239 ctx, prepared_source, display_name, is_url
240 )
241
185 # Cleanup if needed 242 # Cleanup if needed
186 if not success: 243 if not success:
187 await AudioPlaybackManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) 244 await AudioPlaybackManager.cleanup_if_needed(
245 prepared_source, delete_after_play, is_url
246 )
diff --git a/cogs/music/join.py b/cogs/music/join.py
index a12b842..c613b10 100644
--- a/cogs/music/join.py
+++ b/cogs/music/join.py
@@ -3,10 +3,10 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6from discord.ext import commands
7
8import logging 6import logging
9 7
8from discord.ext import commands
9
10logger = logging.getLogger(__name__) 10logger = logging.getLogger(__name__)
11 11
12 12
diff --git a/cogs/music/loop.py b/cogs/music/loop.py
index 34fc04c..6e2c838 100644
--- a/cogs/music/loop.py
+++ b/cogs/music/loop.py
@@ -3,9 +3,10 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6from discord.ext import commands
7import logging 6import logging
8 7
8from discord.ext import commands
9
9logger = logging.getLogger(__name__) 10logger = logging.getLogger(__name__)
10 11
11 12
@@ -20,13 +21,19 @@ class Loop(commands.Cog):
20 def set_looping(self, guild_id, value): 21 def set_looping(self, guild_id, value):
21 self.looping[guild_id] = value 22 self.looping[guild_id] = value
22 23
23 @commands.slash_command(name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji") 24 @commands.slash_command(
25 name="loop", description="włącz/wyłącz loopowanie dla bieżącej melodyji"
26 )
24 async def loop(self, ctx): 27 async def loop(self, ctx):
25 guild_id = ctx.guild.id 28 guild_id = ctx.guild.id
26 looping = not self.is_looping(guild_id) 29 looping = not self.is_looping(guild_id)
27 self.set_looping(guild_id, looping) 30 self.set_looping(guild_id, looping)
28 await ctx.respond(f"looping jest teraz {'włączony' if looping else 'wyłączony'}.") 31 await ctx.respond(
29 logger.info(f"Looping has been {'enabled' if looping else 'disabled'} for guild {guild_id}") 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 )
30 37
31 38
32def setup(bot): 39def setup(bot):
diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py
index 430b51f..e04911c 100644
--- a/cogs/music/media_processing.py
+++ b/cogs/music/media_processing.py
@@ -3,26 +3,26 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import os
7import asyncio 6import asyncio
8import logging 7import logging
9from pathlib import Path 8import os
10 9
11import yt_dlp 10import yt_dlp
12from youtubesearchpython import VideosSearch 11from youtubesearchpython import VideosSearch
13 12
14logger = logging.getLogger(__name__) 13logger = logging.getLogger(__name__)
15 14
15
16class MediaDownloader: 16class MediaDownloader:
17 """Handles downloading media from various sources.""" 17 """Handles downloading media from various sources."""
18 18
19 @staticmethod 19 @staticmethod
20 async def download(ctx, url): 20 async def download(ctx, url):
21 """Download audio from YouTube URL.""" 21 """Download audio from YouTube URL."""
22 # Create temp directory if it doesn't exist 22 # Create temp directory if it doesn't exist
23 temp_dir = os.path.join('cogs', 'temp', 'music') 23 temp_dir = os.path.join("cogs", "temp", "music")
24 os.makedirs(temp_dir, exist_ok=True) 24 os.makedirs(temp_dir, exist_ok=True)
25 25
26 ytdl_opts = { 26 ytdl_opts = {
27 "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), 27 "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"),
28 "postprocessors": [ 28 "postprocessors": [
@@ -62,9 +62,10 @@ class MediaDownloader:
62 await ctx.respond("nic ni ma", ephemeral=True) 62 await ctx.respond("nic ni ma", ephemeral=True)
63 return None 63 return None
64 64
65
65class AudioCompressor: 66class AudioCompressor:
66 """Handles audio compression using FFmpeg.""" 67 """Handles audio compression using FFmpeg."""
67 68
68 @staticmethod 69 @staticmethod
69 async def compress(ctx, filename): 70 async def compress(ctx, filename):
70 """Compress audio file to opus format.""" 71 """Compress audio file to opus format."""
@@ -73,51 +74,63 @@ class AudioCompressor:
73 return None 74 return None
74 75
75 # Create temp directory if it doesn't exist 76 # Create temp directory if it doesn't exist
76 temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp', 'compressed') 77 temp_dir = os.path.join(
78 os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
79 "temp",
80 "compressed",
81 )
77 os.makedirs(temp_dir, exist_ok=True) 82 os.makedirs(temp_dir, exist_ok=True)
78 83
79 # Create a temporary output filename in the temp directory 84 # Create a temporary output filename in the temp directory
80 base_name = os.path.basename(filename) 85 base_name = os.path.basename(filename)
81 output_filename = os.path.join(temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus") 86 output_filename = os.path.join(
82 87 temp_dir, f"{os.path.splitext(base_name)[0]}_compressed.opus"
88 )
89
83 # Ensure the output directory exists 90 # Ensure the output directory exists
84 os.makedirs(os.path.dirname(output_filename) or '.', exist_ok=True) 91 os.makedirs(os.path.dirname(output_filename) or ".", exist_ok=True)
85 92
86 # Use proper quoting for filenames with spaces 93 # Use proper quoting for filenames with spaces
87 ffmpeg_command = [ 94 ffmpeg_command = [
88 'ffmpeg', 95 "ffmpeg",
89 '-y', # Overwrite output file if it exists 96 "-y", # Overwrite output file if it exists
90 '-i', filename, 97 "-i",
91 '-ac', '1', # Mono audio 98 filename,
92 '-b:a', '500', # 500k bitrate 99 "-ac",
93 '-acodec', 'libopus', 100 "1", # Mono audio
94 output_filename 101 "-b:a",
102 "500", # 500k bitrate
103 "-acodec",
104 "libopus",
105 output_filename,
95 ] 106 ]
96 107
97 try: 108 try:
98 logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}") 109 logger.info(f"Running FFmpeg command: {' '.join(ffmpeg_command)}")
99 process = await asyncio.create_subprocess_exec( 110 process = await asyncio.create_subprocess_exec(
100 *ffmpeg_command, 111 *ffmpeg_command,
101 stdout=asyncio.subprocess.PIPE, 112 stdout=asyncio.subprocess.PIPE,
102 stderr=asyncio.subprocess.PIPE 113 stderr=asyncio.subprocess.PIPE,
103 ) 114 )
104 115
105 _, stderr = await process.communicate() 116 _, stderr = await process.communicate()
106 117
107 if process.returncode != 0: 118 if process.returncode != 0:
108 error_msg = stderr.decode() 119 error_msg = stderr.decode()
109 logger.error(f"FFmpeg error ({process.returncode}): {error_msg}") 120 logger.error(f"FFmpeg error ({process.returncode}): {error_msg}")
110 await ctx.respond("eror kompresujync pilk", ephemeral=True) 121 await ctx.respond("eror kompresujync pilk", ephemeral=True)
111 return None 122 return None
112 123
113 if not os.path.exists(output_filename): 124 if not os.path.exists(output_filename):
114 logger.error(f"Output file was not created: {output_filename}") 125 logger.error(f"Output file was not created: {output_filename}")
115 await ctx.respond("eror kompresujync pilk - output file not created", ephemeral=True) 126 await ctx.respond(
127 "eror kompresujync pilk - output file not created", ephemeral=True
128 )
116 return None 129 return None
117 130
118 logger.info(f"Successfully compressed file: {output_filename}") 131 logger.info(f"Successfully compressed file: {output_filename}")
119 return output_filename 132 return output_filename
120 133
121 except Exception as e: 134 except Exception as e:
122 logger.error(f"Error compressing file: {str(e)}", exc_info=True) 135 logger.error(f"Error compressing file: {str(e)}", exc_info=True)
123 await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True) 136 await ctx.respond(f"eror kompresujync pilk: {str(e)}", ephemeral=True)
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index a06c636..a24169f 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -3,88 +3,102 @@
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io 3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import logging
6import os 7import os
7import urllib.parse 8import urllib.parse
8import discord
9from typing import List, Tuple, Callable, Awaitable
10from os import PathLike 9from os import PathLike
10from typing import Awaitable, Callable, List, Tuple
11
12import discord
13from discord.commands import OptionChoice
14from discord.ext import commands
11 15
12from cogs.music import audio_playback, media_processing 16from cogs.music import audio_playback, media_processing
13from cogs.utils import file_search as search_utils 17from cogs.utils import file_search as search_utils
14from discord.ext import commands
15from discord.commands import OptionChoice
16import logging
17 18
18logger = logging.getLogger(__name__) 19logger = logging.getLogger(__name__)
19 20
21
20class PlayCommands(commands.Cog): 22class PlayCommands(commands.Cog):
21 """Discord slash commands for music playback.""" 23 """Discord slash commands for music playback."""
22 24
23 def __init__(self, bot): 25 def __init__(self, bot):
24 self.bot = bot 26 self.bot = bot
25 self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") 27 self.temp_dir = os.path.join(
28 os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp"
29 )
26 self.path_mapping = {} # Maps display names to full paths 30 self.path_mapping = {} # Maps display names to full paths
27 31
28 @staticmethod 32 @staticmethod
29 async def _create_search_embed(candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: 33 async def _create_search_embed(
34 candidates: List[str],
35 ) -> Tuple[discord.Embed, discord.ui.View]:
30 """Create an embed and view for search results.""" 36 """Create an embed and view for search results."""
31 embed = discord.Embed( 37 embed = discord.Embed(
32 title="Wyniki wyszukiwania", 38 title="Wyniki wyszukiwania",
33 description="Wybierz plik do odtworzenia:", 39 description="Wybierz plik do odtworzenia:",
34 color=discord.Color.blue() 40 color=discord.Color.blue(),
35 ) 41 )
36 42
37 view = discord.ui.View(disable_on_timeout=True) 43 view = discord.ui.View(disable_on_timeout=True)
38 44
39 for i, candidate in enumerate(candidates, start=1): 45 for i, candidate in enumerate(candidates, start=1):
40 filename = candidate.split('/')[-1].split('?')[0] 46 filename = candidate.split("/")[-1].split("?")[0]
41 display_name = os.path.splitext(filename)[0] 47 display_name = os.path.splitext(filename)[0]
42 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) 48 embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False)
43 49
44 return embed, view 50 return embed, view
45 51
46 async def _handle_search_results( 52 async def _handle_search_results(
47 self, 53 self,
48 ctx: discord.ApplicationContext, 54 ctx: discord.ApplicationContext,
49 candidates: List[str], 55 candidates: List[str],
50 play_callback: Callable[[discord.Interaction, str], Awaitable[None]] 56 play_callback: Callable[[discord.Interaction, str], Awaitable[None]],
51 ) -> None: 57 ) -> None:
52 """Handle the display of search results and button interactions.""" 58 """Handle the display of search results and button interactions."""
53 if not candidates: 59 if not candidates:
54 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) 60 await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True)
55 return 61 return
56 62
57 embed, view = await self._create_search_embed(candidates) 63 embed, view = await self._create_search_embed(candidates)
58 64
59 # Add buttons for each candidate 65 # Add buttons for each candidate
60 for i, candidate in enumerate(candidates, start=1): 66 for i, candidate in enumerate(candidates, start=1):
61 button = discord.ui.Button(label=f"Play {i}") 67 button = discord.ui.Button(label=f"Play {i}")
62 68
63 async def button_callback(interaction: discord.Interaction, candidate_path=candidate): 69 async def button_callback(
70 interaction: discord.Interaction, candidate_path=candidate
71 ):
64 await interaction.response.defer(ephemeral=True) 72 await interaction.response.defer(ephemeral=True)
65 await ctx.interaction.delete_original_response() 73 await ctx.interaction.delete_original_response()
66 try: 74 try:
67 await play_callback(interaction, candidate_path) 75 await play_callback(interaction, candidate_path)
68 except Exception as e: 76 except Exception as e:
69 logger.error(f"Error in play callback: {str(e)}", exc_info=True) 77 logger.error(f"Error in play callback: {str(e)}", exc_info=True)
70 await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) 78 await interaction.followup.send(
71 79 f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True
80 )
81
72 button.callback = button_callback 82 button.callback = button_callback
73 view.add_item(button) 83 view.add_item(button)
74 84
75 await ctx.respond(embed=embed, view=view, ephemeral=True) 85 await ctx.respond(embed=embed, view=view, ephemeral=True)
76 86
77 async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: 87 async def _search_autocomplete(
88 self, ctx: discord.AutocompleteContext
89 ) -> List[OptionChoice]:
78 """Autocomplete handler for search queries.""" 90 """Autocomplete handler for search queries."""
79 query = ctx.value.lower() 91 query = ctx.value.lower()
80 try: 92 try:
81 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio(query, max_results=25) 93 results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio(
94 query, max_results=25
95 )
82 choices = [] 96 choices = []
83 self.path_mapping = {} # Reset the mapping for each new query 97 self.path_mapping = {} # Reset the mapping for each new query
84 98
85 for url in results: 99 for url in results:
86 filename = url.split('/')[-1] 100 filename = url.split("/")[-1]
87 filename = filename.split('?')[0] # Remove query params if any 101 filename = filename.split("?")[0] # Remove query params if any
88 filename = urllib.parse.unquote(filename) # Decode URL encoding 102 filename = urllib.parse.unquote(filename) # Decode URL encoding
89 display = os.path.splitext(filename)[0] # Remove extension 103 display = os.path.splitext(filename)[0] # Remove extension
90 104
@@ -93,7 +107,7 @@ class PlayCommands(commands.Cog):
93 self.path_mapping[display] = url 107 self.path_mapping[display] = url
94 108
95 if len(display) > 90: 109 if len(display) > 90:
96 display = display[:87] + '...' 110 display = display[:87] + "..."
97 111
98 choices.append(OptionChoice(display[:100], display[:100])) 112 choices.append(OptionChoice(display[:100], display[:100]))
99 113
@@ -106,25 +120,12 @@ class PlayCommands(commands.Cog):
106 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) 120 logger.error(f"Error in autocomplete: {str(e)}", exc_info=True)
107 return [] 121 return []
108 122
109 async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: 123 async def _handle_compressed_file_play(
110 """Validate input parameters for play command.""" 124 self, ctx: discord.ApplicationContext, url: str
111 if not file and not stream: 125 ) -> None:
112 await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True)
113 return False
114
115 if file and stream:
116 await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True)
117 return False
118
119 return True
120
121 async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None:
122 """Handle internet stream playback."""
123 await audio_playback.AudioPlaybackManager.play(ctx, stream)
124
125 async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None:
126 """Download from copyparty, compress, then play locally.""" 126 """Download from copyparty, compress, then play locally."""
127 import aiohttp 127 import aiohttp
128
128 local_path = None 129 local_path = None
129 compressed_path = None 130 compressed_path = None
130 try: 131 try:
@@ -133,21 +134,25 @@ class PlayCommands(commands.Cog):
133 if response.status != 200: 134 if response.status != 200:
134 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) 135 await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True)
135 return 136 return
136 137
137 filename = url.split('/')[-1].split('?')[0] 138 filename = url.split("/")[-1].split("?")[0]
138 filename = urllib.parse.unquote(filename) 139 filename = urllib.parse.unquote(filename)
139 local_path = os.path.join(self.temp_dir, filename) 140 local_path = os.path.join(self.temp_dir, filename)
140 os.makedirs(self.temp_dir, exist_ok=True) 141 os.makedirs(self.temp_dir, exist_ok=True)
141 142
142 with open(local_path, 'wb') as f: 143 with open(local_path, "wb") as f:
143 async for chunk in response.content.iter_chunked(8192): 144 async for chunk in response.content.iter_chunked(8192):
144 f.write(chunk) 145 f.write(chunk)
145 146
146 compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) 147 compressed_path = await media_processing.AudioCompressor.compress(
148 ctx, local_path
149 )
147 if not compressed_path: 150 if not compressed_path:
148 return 151 return
149 152
150 await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) 153 await audio_playback.AudioPlaybackManager.play(
154 ctx, compressed_path, delete_after_play=True
155 )
151 except Exception as e: 156 except Exception as e:
152 logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) 157 logger.error(f"Error in compressed playback: {str(e)}", exc_info=True)
153 await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) 158 await ctx.respond(f"Błąd: {str(e)}", ephemeral=True)
@@ -158,10 +163,12 @@ class PlayCommands(commands.Cog):
158 except Exception: 163 except Exception:
159 pass 164 pass
160 165
161 async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: 166 async def _handle_local_file_play(
167 self, ctx: discord.ApplicationContext, file: str, compressed: bool
168 ) -> None:
162 """Handle file playback from copyparty URL.""" 169 """Handle file playback from copyparty URL."""
163 url = self.path_mapping.get(file, file) 170 url = self.path_mapping.get(file, file)
164 171
165 if compressed: 172 if compressed:
166 await self._handle_compressed_file_play(ctx, url) 173 await self._handle_compressed_file_play(ctx, url)
167 else: 174 else:
@@ -169,46 +176,47 @@ class PlayCommands(commands.Cog):
169 176
170 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") 177 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
171 async def play( 178 async def play(
172 self, 179 self,
173 ctx: discord.ApplicationContext, 180 ctx: discord.ApplicationContext,
174 file: str = discord.Option( 181 melodia: str = discord.Option(
175 str, 182 str,
176 "wyszukaj plik z serwera", 183 "odtwórz plik z serwera bądź strumienia",
177 autocomplete=_search_autocomplete, 184 autocomplete=_search_autocomplete,
178 required=False 185 required=False,
179 ), 186 ),
180 stream: str = discord.Option( 187 file: discord.Attachment = discord.Option(
181 str, 188 discord.Attachment, "plik do zagrania", required=False
182 "odtwórz stream internetowy (e.g. radio)", 189 ),
183 required=False 190 compressed: bool = discord.Option(
184 ), 191 bool, "Czy skompresować dźwięk?", default=False
185 compressed: bool = discord.Option( 192 ),
186 bool,
187 "Czy skompresować dźwięk?",
188 default=False
189 )
190 ): 193 ):
191 """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" 194 """Odtwarza wybrany plik dźwiękowy lub stream internetowy."""
192 await ctx.defer() 195 await ctx.defer()
193 196
194 if not await self._validate_play_inputs(file, stream, ctx): 197 if melodia and file:
198 await ctx.respond(
199 "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True
200 )
195 return 201 return
196
197 try:
198 if stream:
199 await self._handle_stream_play(ctx, stream)
200 else:
201 await self._handle_local_file_play(ctx, file, compressed)
202 except Exception as e:
203 logger.error(f"Error playing: {str(e)}", exc_info=True)
204 await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True)
205 202
206 @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") 203 if not melodia and not file:
207 async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): 204 await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True)
208 await ctx.defer() 205 return
209 file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) 206
210 await attachment.save(file_path) 207 if file:
211 await audio_playback.AudioPlaybackManager.play(ctx, file_path) 208 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename)
209 await file.save(file_path)
210 await audio_playback.AudioPlaybackManager.play(ctx, file_path)
211 else:
212 try:
213 await self._handle_local_file_play(ctx, melodia, compressed)
214 except Exception as e:
215 logger.error(f"Error playing: {str(e)}", exc_info=True)
216 await ctx.respond(
217 f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True
218 )
219
212 220
213def setup(bot): 221def setup(bot):
214 bot.add_cog(PlayCommands(bot)) 222 bot.add_cog(PlayCommands(bot))