aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--AGENTS.md2
-rw-r--r--README.md2
-rw-r--r--cogs/ai.py7
-rw-r--r--cogs/assistant.py112
-rw-r--r--cogs/cron.py26
-rw-r--r--cogs/music/audio_playback.py282
-rw-r--r--cogs/music/join.py4
-rw-r--r--cogs/music/play_commands.py11
-rw-r--r--cogs/music/stop.py5
-rw-r--r--cogs/tts.py16
-rw-r--r--cogs/vclogs.py19
-rw-r--r--cogs/voice.py504
-rw-r--r--main.py1
-rw-r--r--requirements-dev.txt55
-rw-r--r--requirements.txt49
-rw-r--r--scripts/bump_reqs.py50
-rw-r--r--tests/cogs/test_music_audio.py20
-rw-r--r--tests/cogs/test_music_integration.py26
-rw-r--r--tests/cogs/test_music_join.py25
-rw-r--r--tests/cogs/test_voice.py4
20 files changed, 532 insertions, 688 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 8d0b743..5f62ec3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
1# Agent Instructions for Senator Sperma 1# Agent Instructions for Senator Sperma
2 2
3## Required Setup 3## Required Setup
4- Requires **Python 3.13** (3.14 not supported - py-cord incompatibility) 4- Requires **Python 3.14**
5- System dependencies: `ffmpeg`, `zbar` library (installed system-wide) 5- System dependencies: `ffmpeg`, `zbar` library (installed system-wide)
6- Bot token in `.env` file (copy from `.env.example`) 6- Bot token in `.env` file (copy from `.env.example`)
7 7
diff --git a/README.md b/README.md
index 8e71dbe..e9cf127 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
2 2
3A Discord bot with music playback, TTS, web search, and utility commands. 3A Discord bot with music playback, TTS, web search, and utility commands.
4 4
5Requires Python 3.13 (py-cord doesn't support 3.14 yet). 5Requires Python 3.14.
6 6
7## Setup 7## Setup
8 8
diff --git a/cogs/ai.py b/cogs/ai.py
index c9237c4..ef2ba7c 100644
--- a/cogs/ai.py
+++ b/cogs/ai.py
@@ -9,7 +9,6 @@ from discord.ext import commands
9import logging 9import logging
10import httpx 10import httpx
11from gtts import gTTS 11from gtts import gTTS
12from cogs.music import audio_playback
13import io 12import io
14 13
15logger = logging.getLogger(__name__) 14logger = logging.getLogger(__name__)
@@ -61,12 +60,14 @@ class AI(commands.Cog):
61 else: 60 else:
62 response_text = response_data["choices"][0]["message"]["content"] 61 response_text = response_data["choices"][0]["message"]["content"]
63 if tts: 62 if tts:
64 # Generate TTS
65 tts_obj = gTTS(text=response_text, lang='pl') 63 tts_obj = gTTS(text=response_text, lang='pl')
66 tts_buffer = io.BytesIO() 64 tts_buffer = io.BytesIO()
67 tts_obj.write_to_fp(tts_buffer) 65 tts_obj.write_to_fp(tts_buffer)
68 tts_buffer.seek(0) 66 tts_buffer.seek(0)
69 await audio_playback.AudioPlaybackManager.play(ctx, discord.FFmpegPCMAudio(tts_buffer), delete_after_play=True, interrupt=False) 67 from cogs.voice import get_voice_manager
68 vm = get_voice_manager(self.bot)
69 channel = ctx.author.voice.channel
70 await vm.play_source(channel, discord.FFmpegPCMAudio(tts_buffer), interrupt=False)
70 71
71 else: 72 else:
72 await ctx.send_followup(response_text) 73 await ctx.send_followup(response_text)
diff --git a/cogs/assistant.py b/cogs/assistant.py
index a91d027..fed7825 100644
--- a/cogs/assistant.py
+++ b/cogs/assistant.py
@@ -3,16 +3,11 @@
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 re
7import discord 6import discord
8from discord.ext import commands 7from discord.ext import commands
9import httpx 8import httpx
10import json
11import logging 9import logging
12import shutil
13import os 10import os
14from tempfile import NamedTemporaryFile
15from cogs.downloader import Downloader
16 11
17logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
18 13
@@ -46,15 +41,16 @@ class Jarvis(commands.Cog):
46 messages = Jarvis._build_messages(message, chain) 41 messages = Jarvis._build_messages(message, chain)
47 payload = Jarvis._build_payload(messages) 42 payload = Jarvis._build_payload(messages)
48 43
49 response_data = await Jarvis._make_api_request(payload, headers) 44 response_data = await Jarvis._safe_api_call(payload, headers)
50 response_message, action = Jarvis._parse_response(response_data) 45 if response_data is None:
46 await message.reply("Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.")
47 return
48
49 response_message = Jarvis._parse_response(response_data)
51 50
52 channel = message.channel 51 channel = message.channel
53 await message.reply(response_message) 52 await message.reply(response_message)
54 53
55 if action == "download":
56 await Jarvis._handle_download_action(message, channel)
57
58 @staticmethod 54 @staticmethod
59 async def _build_message_chain(message): 55 async def _build_message_chain(message):
60 chain = [] 56 chain = []
@@ -80,10 +76,10 @@ class Jarvis(commands.Cog):
80 messages = [{ 76 messages = [{
81 "role": "system", 77 "role": "system",
82 "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis." 78 "content": "Jesteś Senator Sperma Armostrong, także znany jako Jarvis."
83 "Twoim zadaniem, po za asystowaniem użytkownika, jest mówienie programowi nadrzędnemu jak sie zachować" 79 "Twoim zadaniem jest asystowanie użytkownika"
84 "pole 'message' może być tylko wypełnione w języku Polskim."
85 "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon" 80 "zachowuj sie jak Jarvis, bądź osobistym asystentem użytkownika, nie używaj emotikon"
86 "nie pytaj sie użytkownika czy chcesz abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał" 81 "nie pytaj sie użytkownika czy chce abyś coś zrobił, użytkownik ci sam powie abyś coś wykonał"
82 "Nie pozostawiaj wiadomości pustej oraz nie dawaj swojej wiadomości w pole myślenia"
87 }] 83 }]
88 84
89 if chain is None: 85 if chain is None:
@@ -92,7 +88,7 @@ class Jarvis(commands.Cog):
92 for msg in chain: 88 for msg in chain:
93 messages.append({ 89 messages.append({
94 "role": "user", 90 "role": "user",
95 "content": f"{msg.author.display_name}: {msg.content}" 91 "content": f"USER: {msg.author.display_name}\nMESSAGE: {msg.content}"
96 }) 92 })
97 93
98 return messages 94 return messages
@@ -102,38 +98,12 @@ class Jarvis(commands.Cog):
102 return { 98 return {
103 "model": os.getenv("OPENROUTER_MODEL"), 99 "model": os.getenv("OPENROUTER_MODEL"),
104 "messages": messages, 100 "messages": messages,
105 "response_format": Jarvis._get_response_schema(),
106 "plugins": [{"id": "response-healing"}] 101 "plugins": [{"id": "response-healing"}]
107 } 102 }
108 103
109 @staticmethod 104 @staticmethod
110 def _get_response_schema():
111 return {
112 "type": "json_schema",
113 "json_schema": {
114 "name": "response",
115 "strict": True,
116 "schema": {
117 "type": "object",
118 "properties": {
119 "message": {
120 "type": "string",
121 "description": "the message that user will receive",
122 },
123 "action": {
124 "type": "string",
125 "description": "the action to do, either download, compress or none"
126 }
127 },
128 "required": ["message", "action"],
129 "additionalProperties": False,
130 }
131 }
132 }
133
134 @staticmethod
135 async def _make_api_request(payload, headers): 105 async def _make_api_request(payload, headers):
136 async with httpx.AsyncClient() as client: 106 async with httpx.AsyncClient(timeout=30.0) as client:
137 url = "https://openrouter.ai/api/v1/chat/completions" 107 url = "https://openrouter.ai/api/v1/chat/completions"
138 response = await client.post(url=url, headers=headers, json=payload) 108 response = await client.post(url=url, headers=headers, json=payload)
139 response_data = response.json() 109 response_data = response.json()
@@ -141,62 +111,20 @@ class Jarvis(commands.Cog):
141 return response_data 111 return response_data
142 112
143 @staticmethod 113 @staticmethod
144 def _parse_response(response_data): 114 async def _safe_api_call(payload, headers):
145 content = response_data['choices'][0]['message']['content']
146 inner_json_str = content.strip('```json\n').strip('```').strip()
147 try: 115 try:
148 inner_data = json.loads(inner_json_str) 116 return await Jarvis._make_api_request(payload, headers)
149 response_message = inner_data['message'] 117 except httpx.HTTPError as e:
150 action = inner_data['action'] 118 logger.error(f"API request failed: {e}")
151 except (json.JSONDecodeError, KeyError): 119 return None
152 response_message = content
153 action = "none"
154
155 logger.info(response_message)
156 logger.info(action)
157
158 return response_message, action
159 120
160 @staticmethod 121 @staticmethod
161 async def _handle_download_action(message, channel): 122 def _parse_response(response_data):
162 url_match = Jarvis._extract_url(message.content) 123 content = response_data['choices'][0]['message']['content']
163 if not url_match:
164 await channel.send("Nie znaleziono poprawnego linku w odpowiedzi.")
165 return
166
167 response = await Downloader.download_standalone(url=url_match)
168 await Jarvis._process_download_response(response, channel)
169
170 @staticmethod
171 def _extract_url(content):
172 match = re.search(r'https://(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]{1,63}\.[A-Za-z]{2,63}/\S*', content)
173 return match.group(0) if match else None
174
175 @staticmethod
176 async def _process_download_response(response, channel):
177 if not isinstance(response, dict):
178 return
179 124
180 if response["status"] == "success": 125 logger.info(content)
181 await Jarvis._handle_successful_download(response, channel)
182 else:
183 await Jarvis._handle_failed_download(response)
184 126
185 @staticmethod 127 return content
186 async def _handle_successful_download(response, channel):
187 if response["type"] == "url":
188 await channel.send(f"pobioron i wysyłon: {response['content']}")
189 elif response["type"] == "file":
190 file_data = response["content"]
191 await channel.send("wysyłon:", file=discord.File(file_data))
192 shutil.rmtree(response["temp_dir"])
193
194 @staticmethod
195 async def _handle_failed_download(response):
196 logger.error(f"Error: {response['content']}")
197 if 'details' in response:
198 logger.error(f"Details: {response['details']}")
199 shutil.rmtree(response["temp_dir"])
200 128
201def setup(bot): 129def setup(bot):
202 bot.add_cog(Jarvis(bot)) 130 bot.add_cog(Jarvis(bot))
diff --git a/cogs/cron.py b/cogs/cron.py
index aad43d2..ee88085 100644
--- a/cogs/cron.py
+++ b/cogs/cron.py
@@ -145,29 +145,23 @@ class Cron(commands.Cog):
145 145
146 if isinstance(channel, discord.VoiceChannel): 146 if isinstance(channel, discord.VoiceChannel):
147 voice_mgr = get_voice_manager(self.bot) 147 voice_mgr = get_voice_manager(self.bot)
148 voice_client = await voice_mgr.join_channel(channel)
149 if not voice_client:
150 if channel.guild.system_channel:
151 await channel.guild.system_channel.send(
152 "Nie udano się połączyć z kanałem głosowym"
153 )
154 return
155 148
156 try: 149 try:
157 audio_source = discord.FFmpegPCMAudio(audio_url) 150 audio_source = discord.FFmpegPCMAudio(audio_url)
158 voice_client.play(audio_source) 151 success = await voice_mgr.play_source(channel, audio_source)
159 152
160 while voice_client.is_playing(): 153 if success:
161 await asyncio.sleep(1) 154 logger.info(f"Finished playing audio: {audio_url}")
155 else:
156 raise Exception("Playback failed")
162 157
163 logger.info("Audio playback finished normally - disconnecting") 158 await voice_mgr.disconnect(channel.guild)
164 await voice_client.disconnect()
165 logger.info(f"Finished playing audio: {audio_url}")
166 except Exception as e: 159 except Exception as e:
167 logger.error(f"Error playing audio: {e}") 160 logger.error(f"Error playing audio: {e}")
168 if voice_client.is_connected(): 161 try:
169 logger.info("Audio playback failed - disconnecting") 162 await voice_mgr.disconnect(channel.guild)
170 await voice_client.disconnect() 163 except Exception:
164 pass
171 if channel.guild.system_channel: 165 if channel.guild.system_channel:
172 await channel.guild.system_channel.send( 166 await channel.guild.system_channel.send(
173 f"Błąd podczas odgrywania audio: {audio_url}" 167 f"Błąd podczas odgrywania audio: {audio_url}"
diff --git a/cogs/music/audio_playback.py b/cogs/music/audio_playback.py
deleted file mode 100644
index 1313d01..0000000
--- a/cogs/music/audio_playback.py
+++ /dev/null
@@ -1,282 +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
9import urllib.parse
10
11import discord
12import mutagen
13
14logger = logging.getLogger(__name__)
15
16
17class AudioPlaybackManager:
18 """Handles audio playback, voice connections, and source preparation."""
19
20 @staticmethod
21 async def cleanup_temp_file(source):
22 """Clean up temporary file if it exists."""
23 if source and os.path.exists(source):
24 try:
25 os.remove(source)
26 logger.info(f"Cleaned up temporary file: {source}")
27 except Exception as e:
28 logger.error(f"Error cleaning up file {source}: {str(e)}")
29
30 @staticmethod
31 async def validate_voice_channel(ctx):
32 """Validate user is in a voice channel. Returns channel or None."""
33 if not ctx.author.voice or not ctx.author.voice.channel:
34 await ctx.respond(
35 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
36 )
37 return None
38 return ctx.author.voice.channel
39
40 @staticmethod
41 async def prepare_file_source(source):
42 """Prepare and validate local file source. Returns (display_name, absolute_path) or (None, None)."""
43 logger.info(f"Attempting to play local file: {source}")
44
45 # Get absolute path and verify file exists
46 if not os.path.isabs(source):
47 source = os.path.abspath(source)
48 logger.debug(f"Converted to absolute path: {source}")
49
50 if not os.path.exists(source):
51 logger.error(f"File not found: {source}")
52 return None, None
53
54 filename = os.path.basename(source)
55 display_name = os.path.splitext(filename)[0]
56 return display_name, source
57
58 @staticmethod
59 async def connect_to_voice_channel(ctx, vc, target_channel):
60 """Connect or move to voice channel. Returns voice client."""
61 try:
62 # Check if vc is valid and actually connected
63 if vc is not None and vc.is_connected():
64 # Already connected - check if need to move
65 if vc.channel != target_channel:
66 logger.info(f"Moving to voice channel: {target_channel.name}")
67 await vc.move_to(target_channel)
68 logger.info(f"Moved to voice channel: {target_channel.name}")
69 return vc
70
71 # Not connected - connect now
72 logger.info(f"Connecting to voice channel: {target_channel.name}")
73 vc = await target_channel.connect()
74 logger.info(f"Connected to voice channel: {target_channel.name}")
75 return vc
76 except Exception as e:
77 logger.error(f"Error connecting to voice channel: {str(e)}")
78 raise
79
80 @staticmethod
81 async def prepare_audio_source(source, is_url, display_name):
82 """Prepare audio source for playback. Returns (source, final_display_name)."""
83 if is_url:
84 # For URLs, use FFmpeg with the direct URL and extract clean filename
85 before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
86 audio_source = discord.FFmpegPCMAudio(source, before_options=before_options)
87 # Extract clean filename for URL (remove extension and decode URL encoding)
88 filename = os.path.basename(source.split("?")[0]) # Remove query parameters
89 filename = urllib.parse.unquote(filename) # Decode URL encoding
90 clean_filename = os.path.splitext(filename)[0] # Remove file extension
91 return audio_source, clean_filename
92 else:
93 # For local files, use default options
94 audio_source = discord.FFmpegPCMAudio(source=source)
95
96 # Try to get metadata for local files
97 try:
98 metadata = mutagen.open(source) # type: ignore[call-overload]
99 if metadata:
100 title = (
101 metadata.get("title", [None])[0]
102 if hasattr(metadata, "get")
103 else None
104 )
105 if title:
106 display_name = title
107 logger.debug(f"Read metadata - title: {title}")
108 else:
109 display_name = os.path.splitext(os.path.basename(source))[0]
110 except Exception as e:
111 logger.warning(f"Could not read metadata from {source}: {str(e)}")
112 display_name = os.path.splitext(os.path.basename(source))[0]
113
114 return audio_source, display_name
115
116 @staticmethod
117 async def cleanup_if_needed(source, delete_after_play, is_url):
118 """Clean up temporary file if needed."""
119 if delete_after_play and not is_url:
120 await AudioPlaybackManager.cleanup_temp_file(source)
121
122 @staticmethod
123 async def prepare_source(ctx, source):
124 """Prepare source and get display name. Returns (source, display_name, is_url) or (None, None, None)."""
125 is_url = source.startswith(("http://", "https://"))
126
127 if is_url:
128 logger.info(f"Attempting to play stream from URL: {source}")
129 return source, source, is_url
130
131 display_name, absolute_source = await AudioPlaybackManager.prepare_file_source(
132 source
133 )
134 if not display_name:
135 return None, None, None
136
137 return absolute_source, display_name, is_url
138
139 @staticmethod
140 async def execute_playback(ctx, source, display_name, is_url, interrupt=True):
141 """Execute the actual audio playback.
142
143 Args:
144 ctx: Discord context
145 source: Audio source path or URL
146 display_name: Name to display
147 is_url: Whether source is a URL
148 interrupt: If True, stop any currently playing audio. If False, skip if audio is playing.
149 """
150 target_channel = await AudioPlaybackManager.validate_voice_channel(ctx)
151 if not target_channel:
152 return False
153
154 # Get existing voice client from bot or connect
155 guild = ctx.guild
156 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
157
158 try:
159 # Handle various connection states
160 if vc is None:
161 logger.info("No existing voice client, connecting...")
162 try:
163 vc = await target_channel.connect()
164 except discord.ClientException:
165 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
166 elif not vc.is_connected():
167 logger.info("Voice client exists but disconnected, reconnecting...")
168 try:
169 await vc.disconnect()
170 except Exception:
171 pass
172 try:
173 vc = await target_channel.connect()
174 except discord.ClientException:
175 vc = discord.utils.get(ctx.bot.voice_clients, guild=guild)
176 elif vc.channel != target_channel:
177 logger.info(f"Moving to voice channel: {target_channel.name}")
178 await vc.move_to(target_channel)
179
180 # Stop any currently playing audio (if interrupt is True)
181 if interrupt and (vc.is_playing() or vc.is_paused()): # type: ignore[union-attr]
182 vc.stop() # type: ignore[union-attr]
183 elif not interrupt and vc.is_playing(): # type: ignore[union-attr]
184 logger.info("Audio already playing and interrupt=False, skipping playback")
185 return False
186
187 # Prepare audio source
188 (
189 audio_source,
190 final_display_name,
191 ) = await AudioPlaybackManager.prepare_audio_source(
192 source, is_url, display_name
193 )
194
195 # Track playback state
196 playback_state = {"error": None, "finished": False}
197
198 def after_playing(error):
199 if error:
200 playback_state["error"] = str(error)
201 logger.error(f"Playback error: {error}")
202 playback_state["finished"] = True
203
204 # Play the audio
205 vc.play(audio_source, after=after_playing)
206 logger.info(f"Playing: {final_display_name}")
207 await ctx.followup.send(f"grom: {final_display_name}")
208
209 # Wait for FFmpeg to attempt connection - errors usually happen within 5 seconds
210 await asyncio.sleep(5)
211
212 # Check if playback failed to start (not just finished quickly)
213 if playback_state["error"]:
214 logger.error(f"Playback failed: {playback_state['error']}")
215 await ctx.followup.send(
216 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
217 )
218 return False
219
220 # If playback finished within 5s, it was a short file - not an error
221 if playback_state["finished"]:
222 logger.info(f"Playback completed quickly: {final_display_name}")
223 return True
224
225 if not vc.is_playing():
226 logger.error(f"Stream failed - not playing after 5s: {source}")
227 await ctx.followup.send(
228 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
229 )
230 return False
231
232 return True
233
234 except Exception as e:
235 logger.error(f"Error in play: {str(e)}", exc_info=True)
236 await ctx.followup.send(f"eror: {str(e)}", ephemeral=True)
237 return False
238
239 @staticmethod
240 async def validate_and_prepare_source(ctx, source):
241 """Validate and prepare source for playback. Returns (source, display_name, is_url) or (None, None, None)."""
242 (
243 prepared_source,
244 display_name,
245 is_url,
246 ) = await AudioPlaybackManager.prepare_source(ctx, source)
247 if not prepared_source:
248 return None, None, None
249 return prepared_source, display_name, is_url
250
251 @staticmethod
252 async def play(ctx, source, delete_after_play=False, interrupt=True):
253 """Play audio from a local file or URL.
254
255 Args:
256 ctx: Discord context
257 source: Either a local file path or a URL to stream from
258 delete_after_play: If True, delete the file after playing (only for local files)
259 interrupt: If True, stop any currently playing audio. If False, skip if audio is playing.
260 """
261 # Validate and prepare source
262 (
263 prepared_source,
264 display_name,
265 is_url,
266 ) = await AudioPlaybackManager.validate_and_prepare_source(ctx, source)
267 if not prepared_source:
268 await AudioPlaybackManager.cleanup_if_needed(
269 source, delete_after_play, False
270 )
271 return
272
273 # Execute playback
274 success = await AudioPlaybackManager.execute_playback(
275 ctx, prepared_source, display_name, is_url, interrupt
276 )
277
278 # Cleanup if needed
279 if not success:
280 await AudioPlaybackManager.cleanup_if_needed(
281 prepared_source, delete_after_play, is_url
282 )
diff --git a/cogs/music/join.py b/cogs/music/join.py
index c613b10..d68ca79 100644
--- a/cogs/music/join.py
+++ b/cogs/music/join.py
@@ -6,6 +6,7 @@
6import logging 6import logging
7 7
8from discord.ext import commands 8from discord.ext import commands
9from cogs.voice import get_voice_manager
9 10
10logger = logging.getLogger(__name__) 11logger = logging.getLogger(__name__)
11 12
@@ -16,8 +17,9 @@ class Join(commands.Cog):
16 17
17 @commands.slash_command(name="join", description="dołącza do kanalu") 18 @commands.slash_command(name="join", description="dołącza do kanalu")
18 async def join(self, ctx): 19 async def join(self, ctx):
20 voice_mgr = get_voice_manager(self.bot)
19 channel = ctx.author.voice.channel 21 channel = ctx.author.voice.channel
20 await channel.connect() 22 await voice_mgr.connect(channel)
21 await ctx.respond(content="\u200b", ephemeral=True) 23 await ctx.respond(content="\u200b", ephemeral=True)
22 await ctx.interaction.delete_original_response() 24 await ctx.interaction.delete_original_response()
23 logger.info("Bot connected to voice channel") 25 logger.info("Bot connected to voice channel")
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py
index 80a30ef..c6a55b0 100644
--- a/cogs/music/play_commands.py
+++ b/cogs/music/play_commands.py
@@ -17,10 +17,11 @@ import discord
17from discord.commands import OptionChoice 17from discord.commands import OptionChoice
18from discord.ext import commands 18from discord.ext import commands
19 19
20from cogs.music import audio_playback, media_processing 20from cogs.music import media_processing
21from cogs.utils.file_search import UniversalSearcher 21from cogs.utils.file_search import UniversalSearcher
22from cogs.utils.settings import get_uploadurl 22from cogs.utils.settings import get_uploadurl
23from cogs.utils.url_helper import clean_url_for_ytdlp 23from cogs.utils.url_helper import clean_url_for_ytdlp
24from cogs.voice import get_voice_manager
24 25
25logger = logging.getLogger(__name__) 26logger = logging.getLogger(__name__)
26 27
@@ -163,7 +164,7 @@ class PlayCommands(commands.Cog):
163 if not compressed_path: 164 if not compressed_path:
164 return 165 return
165 166
166 await audio_playback.AudioPlaybackManager.play( 167 await get_voice_manager(self.bot).play(
167 ctx, compressed_path, delete_after_play=True 168 ctx, compressed_path, delete_after_play=True
168 ) 169 )
169 except Exception as e: 170 except Exception as e:
@@ -210,7 +211,7 @@ class PlayCommands(commands.Cog):
210 211
211 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path)) 212 upload_task = asyncio.create_task(self._upload_to_copyparty_async(local_path))
212 213
213 await audio_playback.AudioPlaybackManager.play( 214 await get_voice_manager(self.bot).play(
214 ctx, local_path, delete_after_play=True 215 ctx, local_path, delete_after_play=True
215 ) 216 )
216 217
@@ -266,7 +267,7 @@ class PlayCommands(commands.Cog):
266 if compressed: 267 if compressed:
267 await self._handle_compressed_file_play(ctx, url) 268 await self._handle_compressed_file_play(ctx, url)
268 else: 269 else:
269 await audio_playback.AudioPlaybackManager.play(ctx, url) 270 await get_voice_manager(self.bot).play(ctx, url)
270 271
271 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") 272 @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!")
272 async def play( 273 async def play(
@@ -301,7 +302,7 @@ class PlayCommands(commands.Cog):
301 if file: 302 if file:
302 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) 303 file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename)
303 await file.save(file_path) 304 await file.save(file_path)
304 await audio_playback.AudioPlaybackManager.play(ctx, file_path) 305 await get_voice_manager(self.bot).play(ctx, file_path)
305 else: 306 else:
306 try: 307 try:
307 if EXTERNAL_URL_PATTERN.match(melodia.lower()): 308 if EXTERNAL_URL_PATTERN.match(melodia.lower()):
diff --git a/cogs/music/stop.py b/cogs/music/stop.py
index f06e7f5..9c8d5cf 100644
--- a/cogs/music/stop.py
+++ b/cogs/music/stop.py
@@ -5,6 +5,8 @@
5 5
6from discord.ext import commands 6from discord.ext import commands
7 7
8from cogs.voice import get_voice_manager
9
8import logging 10import logging
9 11
10logger = logging.getLogger(__name__) 12logger = logging.getLogger(__name__)
@@ -18,7 +20,8 @@ class Stop(commands.Cog):
18 async def stop(self, ctx): 20 async def stop(self, ctx):
19 await ctx.respond(content="\u200b", ephemeral=True) 21 await ctx.respond(content="\u200b", ephemeral=True)
20 await ctx.interaction.delete_original_response() 22 await ctx.interaction.delete_original_response()
21 await ctx.voice_client.disconnect() 23 voice_mgr = get_voice_manager(self.bot)
24 await voice_mgr.disconnect(ctx.guild)
22 logger.info("Bot disconnected from voice channel") 25 logger.info("Bot disconnected from voice channel")
23 26
24 27
diff --git a/cogs/tts.py b/cogs/tts.py
index 68eb27f..ca81d3a 100644
--- a/cogs/tts.py
+++ b/cogs/tts.py
@@ -42,28 +42,26 @@ class TTS(commands.Cog):
42 await ctx.respond(content="\u200b", ephemeral=True) 42 await ctx.respond(content="\u200b", ephemeral=True)
43 await ctx.interaction.delete_original_response() 43 await ctx.interaction.delete_original_response()
44 44
45 # Check if the user is in a voice channel
46 if not (ctx.author.voice and ctx.author.voice.channel): 45 if not (ctx.author.voice and ctx.author.voice.channel):
47 logger.info("User is not in a voice channel") 46 logger.info("User is not in a voice channel")
48 return 47 return
49 48
50 channel = ctx.author.voice.channel 49 channel = ctx.author.voice.channel
51 try: 50 try:
52 voice_client = await self._get_or_connect_voice_client(channel) 51 voice_mgr = self._get_voice_manager()
53 52
54 if not voice_client: 53 if voice_mgr.get_voice_client and await voice_mgr.get_client(channel.guild):
55 await ctx.respond("Failed to connect to voice channel", ephemeral=True) 54 vc = await voice_mgr.get_client(channel.guild)
56 return 55 if vc and vc.is_playing():
57 56 logger.info("Audio already playing, skipping TTS")
58 if voice_client.is_playing(): 57 return
59 await ctx.respond("Audio is already playing, try again later", ephemeral=True)
60 return
61 58
62 audio_source = discord.FFmpegPCMAudio( 59 audio_source = discord.FFmpegPCMAudio(
63 temp_path, 60 temp_path,
64 options=f'-filter:a "volume={volume}"' 61 options=f'-filter:a "volume={volume}"'
65 ) 62 )
66 logger.info(f"Playing TTS: {text}") 63 logger.info(f"Playing TTS: {text}")
64 await voice_mgr.play_source(channel, audio_source)
67 65
68 except Exception as e: 66 except Exception as e:
69 logger.error(f"Error in voice channel handling: {e}") 67 logger.error(f"Error in voice channel handling: {e}")
diff --git a/cogs/vclogs.py b/cogs/vclogs.py
index e5ca202..35826d4 100644
--- a/cogs/vclogs.py
+++ b/cogs/vclogs.py
@@ -116,7 +116,7 @@ class VcLogs(commands.Cog):
116 116
117 voice_mgr = self.get_voice_manager() 117 voice_mgr = self.get_voice_manager()
118 temp_dir = get_temp_dir() 118 temp_dir = get_temp_dir()
119 tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") 119 tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3")
120 tts = gTTS(text=text, lang='pl') 120 tts = gTTS(text=text, lang='pl')
121 logger.info("saving file") 121 logger.info("saving file")
122 122
@@ -129,6 +129,16 @@ class VcLogs(commands.Cog):
129 is_connected = voice_status[1] if voice_status else False 129 is_connected = voice_status[1] if voice_status else False
130 is_playing = voice_status[2] if voice_status else False 130 is_playing = voice_status[2] if voice_status else False
131 131
132 if is_connected:
133 from cogs.voice import is_voice_client_alive
134 if not is_voice_client_alive(voice_client, channel.guild):
135 logger.warning("Stale voice client in play_tts, reconnecting")
136 try:
137 await voice_client.disconnect()
138 except Exception:
139 pass
140 is_connected = False
141
132 if not is_connected: 142 if not is_connected:
133 voice_client = await voice_mgr.join_channel(channel) 143 voice_client = await voice_mgr.join_channel(channel)
134 voice_status = await voice_mgr.get_voice_status(channel.guild) 144 voice_status = await voice_mgr.get_voice_status(channel.guild)
@@ -150,12 +160,7 @@ class VcLogs(commands.Cog):
150 160
151 logger.info("preparing to play audio") 161 logger.info("preparing to play audio")
152 audio_source = discord.FFmpegPCMAudio(tts_file) 162 audio_source = discord.FFmpegPCMAudio(tts_file)
153 if volume != 1.0: 163 await voice_mgr.play_source(channel, audio_source, volume=volume)
154 audio_source = discord.PCMVolumeTransformer(audio_source)
155 audio_source.volume = volume
156 voice_client.play(audio_source)
157 while voice_client.is_playing():
158 await asyncio.sleep(0.1)
159 except Exception as e: 164 except Exception as e:
160 logger.error(f"TTS playback error: {e}") 165 logger.error(f"TTS playback error: {e}")
161 finally: 166 finally:
diff --git a/cogs/voice.py b/cogs/voice.py
index 046c2a8..a224336 100644
--- a/cogs/voice.py
+++ b/cogs/voice.py
@@ -4,12 +4,15 @@
4# https://codeberg.org/salmonik/senator-sperma 4# https://codeberg.org/salmonik/senator-sperma
5 5
6import discord 6import discord
7from discord.ext import commands
8import logging 7import logging
9import asyncio 8import asyncio
9import os
10import urllib.parse
10from dataclasses import dataclass, field 11from dataclasses import dataclass, field
11from typing import Any 12from typing import Any
12 13
14import mutagen
15
13logger = logging.getLogger(__name__) 16logger = logging.getLogger(__name__)
14 17
15 18
@@ -24,60 +27,171 @@ def get_voice_manager(bot) -> "VoiceManager":
24 return _voice_managers[bot_id] 27 return _voice_managers[bot_id]
25 28
26 29
30def is_voice_client_alive(voice_client, guild):
31 """Check if a voice client connection is healthy."""
32 if voice_client is None:
33 return False
34 if not voice_client.is_connected():
35 return False
36 if voice_client.channel is None:
37 return False
38 if guild.get_channel(voice_client.channel.id) is None:
39 return False
40
41 try:
42 ws = voice_client._connection
43 s = ws.socket
44 if s is None or s.fileno() == -1:
45 return False
46 except Exception:
47 return False
48
49 return True
50
51
27@dataclass 52@dataclass
28class VoiceManager: 53class VoiceManager:
29 bot: Any # type: ignore[no-any-import] 54 bot: Any
30 _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) 55 _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
56 _connecting: set[int] = field(default_factory=set, init=False)
31 57
32 async def join_channel(self, channel): 58 # --- Connection -----------------------------------------------------------
33 """Join a voice channel by channel object.""" 59
34 async with self._voice_lock: 60 async def _ensure_voice_client(self, channel):
35 guild = channel.guild 61 """Unified connect/move/reconnect logic.
36 voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) 62
37 63 Returns a healthy VoiceClient connected to *channel*, or None on failure.
38 if voice_client and voice_client.is_connected(): 64 """
39 if voice_client.channel == channel: 65 guild = channel.guild
40 logger.info(f"Already connected to {channel}") 66 guild_id = guild.id
41 return voice_client 67
42 try: 68 async with self._lock:
43 await voice_client.move_to(channel) 69 if guild_id in self._connecting:
44 logger.info(f"Moved to voice channel: {channel}") 70 logger.info(f"Connection already in progress for guild {guild_id}, skipping")
45 return voice_client 71 return None
46 except Exception as e: 72
47 logger.error(f"Failed to move voice client: {e}") 73 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
48 logger.info("Disconnecting after failed move attempt") 74
75 # --- resolve existing client --------------------------------------
76 if vc is not None:
77 if vc.is_connected():
78 if vc.channel == channel:
79 if is_voice_client_alive(vc, guild):
80 logger.info(f"Already connected to {channel}")
81 return vc
82 logger.warning(f"Stale connection to {channel}, reconnecting")
83 # Disconnect resets the state machine to 'disconnected'
84 # but keeps the VoiceClient alive. Reusing it avoids a
85 # race where Discord sends VOICE_SERVER_UPDATE between
86 # the destroy and the fresh-connect below, causing the
87 # new client to timeout waiting for the event (since the
88 # old one already consumed it).
89 try:
90 await vc.disconnect()
91 except Exception:
92 pass
93 try:
94 await vc.connect(
95 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
96 )
97 logger.info(f"Reconnected to voice channel: {channel}")
98 return vc
99 except Exception as e:
100 logger.error(f"Failed to reconnect stale voice client: {e}")
101 try:
102 channel._state._remove_voice_client(guild.id)
103 except Exception:
104 pass
105 vc = None
106 else:
107 try:
108 await vc.move_to(channel)
109 logger.info(f"Moved to voice channel: {channel}")
110 return vc
111 except Exception as e:
112 logger.error(f"Failed to move voice client: {e}")
113 try:
114 await vc.disconnect()
115 except Exception:
116 pass
117 try:
118 await vc.connect(
119 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
120 )
121 logger.info(f"Reconnected after failed move: {channel}")
122 return vc
123 except Exception:
124 pass
125 try:
126 channel._state._remove_voice_client(guild.id)
127 except Exception:
128 pass
129 vc = None
130 else:
131 logger.info("Voice client exists but disconnected, reconnecting")
49 try: 132 try:
50 await voice_client.disconnect() 133 await vc.disconnect()
51 except Exception: 134 except Exception:
52 pass 135 pass
53 136 try:
54 await asyncio.sleep(0.3) 137 await vc.connect(
55 try: 138 reconnect=False, timeout=10.0, self_deaf=False, self_mute=False
56 voice_client = await channel.connect(timeout=30.0, reconnect=False) 139 )
57 logger.info(f"Connected to voice channel: {channel}") 140 logger.info(f"Reconnected disconnected client: {channel}")
58 return voice_client 141 return vc
59 except discord.ClientException as e: 142 except Exception as e:
60 if "Already connected" in str(e): 143 logger.error(f"Failed to reconnect disconnected client: {e}")
61 logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}")
62 voice_client = discord.utils.get(self.bot.voice_clients, guild=guild)
63 if voice_client:
64 try: 144 try:
65 await voice_client.disconnect() 145 channel._state._remove_voice_client(guild.id)
66 except Exception: 146 except Exception:
67 pass 147 pass
68 await asyncio.sleep(0.3) 148 vc = None
69 try: 149
70 voice_client = await channel.connect(timeout=30.0, reconnect=False) 150 # --- create fresh connection --------------------------------------
71 logger.info(f"Reconnected to voice channel: {channel}") 151 self._connecting.add(guild_id)
72 return voice_client 152 try:
73 except Exception as e2: 153 await asyncio.sleep(0.3)
74 logger.error(f"Failed to reconnect after stale cleanup: {e2}") 154 try:
75 return None 155 vc = await channel.connect(timeout=10.0, reconnect=False)
76 logger.error(f"Failed to connect to voice channel: {e}") 156 logger.info(f"Connected to voice channel: {channel}")
77 return None 157 return vc
78 except Exception as e: 158 except discord.ClientException as e:
79 logger.error(f"Failed to connect to voice channel: {e}") 159 if "Already connected" in str(e):
80 return None 160 logger.warning(f"Stale voice state detected, forcing reconnect: {e}")
161 vc = discord.utils.get(self.bot.voice_clients, guild=guild)
162 if vc:
163 try:
164 await vc.disconnect()
165 except Exception:
166 pass
167 else:
168 try:
169 channel._state._remove_voice_client(guild.id)
170 except Exception:
171 pass
172 await asyncio.sleep(0.3)
173 try:
174 vc = await channel.connect(timeout=10.0, reconnect=False)
175 logger.info(f"Reconnected to voice channel: {channel}")
176 return vc
177 except Exception as e2:
178 logger.error(f"Failed to reconnect after stale cleanup: {e2}")
179 return None
180 logger.error(f"Failed to connect to voice channel: {e}")
181 return None
182 except Exception as e:
183 logger.error(f"Failed to connect to voice channel: {e}")
184 return None
185 finally:
186 self._connecting.discard(guild_id)
187
188 async def connect(self, channel):
189 """Join a voice channel (public alias)."""
190 return await self._ensure_voice_client(channel)
191
192 async def join_channel(self, channel):
193 """Backward-compatible alias for connect()."""
194 return await self.connect(channel)
81 195
82 async def join_by_channel_id(self, channel_id): 196 async def join_by_channel_id(self, channel_id):
83 """Join a voice channel by channel ID.""" 197 """Join a voice channel by channel ID."""
@@ -88,135 +202,243 @@ class VoiceManager:
88 if not isinstance(channel, discord.VoiceChannel): 202 if not isinstance(channel, discord.VoiceChannel):
89 logger.error(f"Channel {channel_id} is not a voice channel") 203 logger.error(f"Channel {channel_id} is not a voice channel")
90 return None 204 return None
91 return await self.join_channel(channel) 205 return await self.connect(channel)
92 206
93 async def get_voice_client(self, guild): 207 async def get_client(self, guild):
94 """Get existing voice client for a guild.""" 208 """Get existing voice client for a guild."""
95 return discord.utils.get(self.bot.voice_clients, guild=guild) 209 return discord.utils.get(self.bot.voice_clients, guild=guild)
96 210
97 async def get_voice_status(self, guild): 211 async def get_voice_client(self, guild):
98 """Get voice client status including connection and playback state. 212 """Backward-compatible alias for get_client()."""
213 return await self.get_client(guild)
214
215 async def get_status(self, guild):
216 """Get voice client status.
99 217
100 Returns: 218 Returns:
101 tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client 219 tuple: (voice_client, is_connected, is_playing) or (None, False, False)
102 """ 220 """
103 voice_client = await self.get_voice_client(guild) 221 vc = await self.get_client(guild)
104 if voice_client is None: 222 if vc is None:
105 return None, False, False 223 return None, False, False
224 return vc, vc.is_connected(), vc.is_playing()
106 225
107 is_connected = voice_client.is_connected() 226 async def get_voice_status(self, guild):
108 is_playing = voice_client.is_playing() if is_connected else False 227 """Backward-compatible alias for get_status()."""
228 return await self.get_status(guild)
229
230 async def disconnect(self, guild):
231 """Stop playback and disconnect from voice."""
232 vc = await self.get_client(guild)
233 if vc:
234 vc.stop()
235 logger.info("Disconnecting from voice")
236 await vc.disconnect()
237 logger.info(f"Disconnected from guild {guild}")
109 238
110 return voice_client, is_connected, is_playing 239 async def stop(self, guild):
240 """Backward-compatible alias for disconnect()."""
241 await self.disconnect(guild)
111 242
112 async def play_audio(self, channel, audio_source, volume=1.0): 243 # --- Playback ------------------------------------------------------------
113 """Play audio in a voice channel."""
114 voice_client = await self.join_channel(channel)
115 if not voice_client:
116 return False
117 244
118 if abs(volume - 1.0) > 1e-9: 245 async def _play_await(self, vc, audio_source):
119 audio_source = discord.PCMVolumeTransformer(audio_source) 246 """Play an audio source and wait for completion using an asyncio.Future.
120 audio_source.volume = volume 247
248 Returns True on success, False on error. No polling, no magic sleeps.
249 """
250 loop = asyncio.get_event_loop()
251 future = loop.create_future()
252
253 def after(error):
254 if not future.done():
255 if error:
256 future.set_exception(Exception(str(error)))
257 else:
258 future.set_result(True)
259
260 vc.play(audio_source, after=after)
121 261
122 try: 262 try:
123 voice_client.play(audio_source) 263 await future
124 while voice_client.is_playing():
125 await asyncio.sleep(0.1)
126 return True 264 return True
127 except Exception as e: 265 except Exception as e:
128 logger.error(f"Error playing audio: {e}") 266 logger.error(f"Playback error: {e}")
129 return False 267 return False
130 268
131 async def stop(self, guild): 269 async def play_source(self, channel, audio_source, interrupt=True, volume=1.0):
132 """Stop playing and disconnect from voice.""" 270 """Connect to *channel* and play a pre-made audio source.
133 voice_client = await self.get_voice_client(guild)
134 if voice_client:
135 voice_client.stop()
136 logger.info("stop() called - disconnecting from voice")
137 await voice_client.disconnect()
138 logger.info(f"Stopped and disconnected from guild {guild}")
139 271
272 Returns True on success, False on failure.
273 """
274 vc = await self.connect(channel)
275 if not vc:
276 logger.error("play_source: could not connect to voice channel")
277 return False
140 278
141class Voice(commands.Cog): 279 if interrupt and (vc.is_playing() or vc.is_paused()):
142 def __init__(self, bot): 280 vc.stop()
143 self.bot = bot
144 self._voice_lock = asyncio.Lock()
145 281
146 async def _validate_user_voice_state(self, ctx): 282 if abs(volume - 1.0) > 1e-9:
147 if not ctx.author.voice or not ctx.author.voice.channel: 283 audio_source = discord.PCMVolumeTransformer(audio_source)
148 await ctx.send("You need to be in a voice channel first!") 284 audio_source.volume = volume
149 return False
150 return True
151 285
152 async def _create_voice_client(self, ctx): 286 logger.info("Playing via play_source")
153 async with self._voice_lock: 287 return await self._play_await(vc, audio_source)
154 try:
155 await asyncio.sleep(0.5)
156 voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False)
157 logger.info(f"Connected to voice channel: {ctx.author.voice.channel}")
158 return voice_client
159 except asyncio.TimeoutError:
160 await ctx.send("Connection to voice channel timed out.")
161 return None
162 except discord.ClientException as e:
163 logger.error(f"Failed to connect to voice channel: {e}")
164 await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True)
165 return None
166 288
167 async def _move_voice_client(self, voice_client, ctx): 289 # --- Source preparation (folded from AudioPlaybackManager) ---------------
168 try: 290
169 await voice_client.move_to(ctx.author.voice.channel) 291 @staticmethod
170 logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") 292 async def validate_voice_channel(ctx):
171 return voice_client 293 """Validate user is in a voice channel. Returns the channel or None."""
172 except asyncio.TimeoutError: 294 if not ctx.author.voice or not ctx.author.voice.channel:
173 await ctx.send("Connection to voice channel timed out.") 295 await ctx.respond(
174 return None 296 "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True
175 except discord.ClientException as e: 297 )
176 logger.error(f"Failed to move to voice channel: {e}")
177 await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True)
178 return None 298 return None
299 return ctx.author.voice.channel
179 300
180 async def _verify_connection(self, voice_client): 301 @staticmethod
181 if not voice_client.is_connected(): 302 async def prepare_file_source(source):
303 """Validate a local file and return (display_name, absolute_path) or (None, None)."""
304 logger.info(f"Attempting to play local file: {source}")
305
306 if not os.path.isabs(source):
307 source = os.path.abspath(source)
308 logger.debug(f"Converted to absolute path: {source}")
309
310 if not os.path.exists(source):
311 logger.error(f"File not found: {source}")
312 return None, None
313
314 filename = os.path.basename(source)
315 display_name = os.path.splitext(filename)[0]
316 return display_name, source
317
318 @staticmethod
319 async def prepare_audio_source(source, is_url, display_name):
320 """Prepare FFmpegPCMAudio from a URL or local file.
321
322 Returns (audio_source, final_display_name).
323 """
324 if is_url:
325 before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
326 audio_source = discord.FFmpegPCMAudio(source, before_options=before_options)
327 filename = os.path.basename(source.split("?")[0])
328 filename = urllib.parse.unquote(filename)
329 clean_filename = os.path.splitext(filename)[0]
330 return audio_source, clean_filename
331 else:
332 audio_source = discord.FFmpegPCMAudio(source=source)
182 try: 333 try:
183 await voice_client.connect(reconnect=False, timeout=10.0) 334 metadata = mutagen.open(source)
184 return voice_client 335 if metadata:
336 title = (
337 metadata.get("title", [None])[0]
338 if hasattr(metadata, "get")
339 else None
340 )
341 if title:
342 display_name = title
343 logger.debug(f"Read metadata - title: {title}")
344 else:
345 display_name = os.path.splitext(os.path.basename(source))[0]
185 except Exception as e: 346 except Exception as e:
186 logger.error(f"Failed to reconnect to voice: {e}") 347 logger.warning(f"Could not read metadata from {source}: {str(e)}")
187 return None 348 display_name = os.path.splitext(os.path.basename(source))[0]
188 return voice_client
189 349
190 async def ensure_voice_client(self, ctx): 350 return audio_source, display_name
191 if not await self._validate_user_voice_state(ctx): 351
192 return None 352 @staticmethod
353 async def prepare_source(source):
354 """Determine whether *source* is a URL or local file.
355
356 Returns (source_path, display_name, is_url) or (None, None, None) on failure.
357 """
358 is_url = source.startswith(("http://", "https://"))
359
360 if is_url:
361 logger.info(f"Attempting to play stream from URL: {source}")
362 return source, source, is_url
193 363
194 voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) 364 display_name, absolute_source = await VoiceManager.prepare_file_source(source)
365 if not display_name:
366 return None, None, None
195 367
196 if voice_client and not voice_client.is_connected(): 368 return absolute_source, display_name, is_url
369
370 @staticmethod
371 async def cleanup_temp_file(source):
372 """Remove a temporary file if it exists."""
373 if source and os.path.exists(source):
197 try: 374 try:
198 logger.info("Voice client exists but not connected - disconnecting stale client") 375 os.remove(source)
199 await voice_client.disconnect() 376 logger.info(f"Cleaned up temporary file: {source}")
200 except Exception: 377 except Exception as e:
201 pass 378 logger.error(f"Error cleaning up file {source}: {str(e)}")
202 voice_client = None
203 379
204 if not voice_client or not voice_client.is_connected(): 380 @staticmethod
205 if not await self._validate_user_voice_state(ctx): 381 async def cleanup_if_needed(source, delete_after_play, is_url):
206 return None 382 """Conditionally clean up a file after playback."""
207 return await self._create_voice_client(ctx) 383 if delete_after_play and not is_url:
384 await VoiceManager.cleanup_temp_file(source)
208 385
209 if voice_client.channel != ctx.author.voice.channel: 386 # --- High-level play (full flow) -----------------------------------------
210 return await self._move_voice_client(voice_client, ctx)
211 387
212 return await self._verify_connection(voice_client) 388 async def play(self, ctx, source, delete_after_play=False, interrupt=True):
389 """High-level playback: validate context, prepare source, connect, play, cleanup.
213 390
214 async def connect(self, ctx): 391 Sends ``grom: {name}`` immediately on start, and an error follow-up
215 voice_client = await self.ensure_voice_client(ctx) 392 if playback fails.
216 if not voice_client: 393
217 return None 394 Args:
218 return voice_client 395 ctx: Discord application context (used for channel resolution & error messages).
396 source: Local file path or URL to stream.
397 delete_after_play: Remove local file after playback (no-op for URLs).
398 interrupt: Stop currently playing audio before starting.
399
400 Returns:
401 bool: True if playback completed successfully, False otherwise.
402 """
403 prepared_source, display_name, is_url = await VoiceManager.prepare_source(source)
404 if not prepared_source:
405 await VoiceManager.cleanup_if_needed(source, delete_after_play, False)
406 return False
407
408 target_channel = await VoiceManager.validate_voice_channel(ctx)
409 if not target_channel:
410 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
411 return False
412
413 vc = await self.connect(target_channel)
414 if not vc:
415 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
416 return False
417
418 if interrupt and (vc.is_playing() or vc.is_paused()):
419 vc.stop()
420
421 audio_source, final_display_name = await VoiceManager.prepare_audio_source(
422 prepared_source, is_url, display_name
423 )
424
425 try:
426 await ctx.followup.send(f"grom: {final_display_name}")
427 except Exception as e:
428 logger.error(e)
429
430 success = await self._play_await(vc, audio_source)
219 431
432 if success:
433 logger.info(f"Playback completed: {final_display_name}")
434 else:
435 logger.error(f"Playback failed: {prepared_source}")
436 try:
437 await ctx.followup.send(
438 "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True
439 )
440 except Exception:
441 pass
442 await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url)
220 443
221def setup(bot): 444 return success
222 bot.add_cog(Voice(bot))
diff --git a/main.py b/main.py
index 8f75ef4..cab1f54 100644
--- a/main.py
+++ b/main.py
@@ -75,7 +75,6 @@ if __name__ == "__main__":
75 "cogs.trigger", 75 "cogs.trigger",
76 "cogs.cron", 76 "cogs.cron",
77 "cogs.qr", 77 "cogs.qr",
78 "cogs.voice",
79 "cogs.uploader", 78 "cogs.uploader",
80 ] 79 ]
81 80
diff --git a/requirements-dev.txt b/requirements-dev.txt
index abba086..ed5f5cc 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,59 +1,18 @@
1pytest==8.3.4 1pytest==9.0.3
2pytest-asyncio==0.25.2 2pytest-asyncio==1.3.0
3pytest-mock==3.14.0 3basedpyright==1.39.5
4aiofiles==25.1.0 4aiofiles==25.1.0
5aiohappyeyeballs==2.6.1 5aiohttp==3.13.5
6aiohttp==3.13.3
7aiosignal==1.4.0
8anyio==4.12.1
9attrs==25.4.0
10basedpyright==1.39.2
11beautifulsoup4==4.14.3 6beautifulsoup4==4.14.3
12brotli==1.2.0
13bs4==0.0.2
14certifi==2026.2.25
15cffi==2.0.0
16charset-normalizer==3.4.6
17click==8.1.8
18contourpy==1.3.3
19croniter==6.2.2 7croniter==6.2.2
20cycler==0.12.1
21davey==0.1.4
22dotenv==0.9.9
23fonttools==4.62.1
24frozenlist==1.8.0
25gTTS==2.5.4 8gTTS==2.5.4
26h11==0.16.0
27httpcore==1.0.9
28httpx==0.28.1 9httpx==0.28.1
29idna==3.11
30kiwisolver==1.5.0
31matplotlib==3.10.8
32multidict==6.7.1
33mutagen==1.47.0 10mutagen==1.47.0
34nodejs-wheel-binaries==24.14.1
35numpy==2.4.4
36packaging==26.1
37pillow==12.2.0 11pillow==12.2.0
38propcache==0.4.1 12py-cord==2.8.0
39py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord
40pycparser==3.0
41pycryptodomex==3.23.0
42PyNaCl==1.6.2
43pyparsing==3.3.2
44python-dateutil==2.9.0.post0
45python-dotenv==1.2.2 13python-dotenv==1.2.2
46pytube==15.0.0
47pyzbar==0.1.9 14pyzbar==0.1.9
48qrcode==8.2 15qrcode==8.2
49RapidFuzz==3.14.3 16requests==2.34.2
50requests==2.32.5
51six==1.17.0
52soupsieve==2.8.3
53typing_extensions==4.15.0
54urllib3==2.6.3
55websockets==16.0
56yarl==1.23.0
57youtube-search-python==1.6.6
58yt-dlp==2026.3.17 17yt-dlp==2026.3.17
59yt-dlp-ejs==0.8.0 18youtube-search-python==1.6.6
diff --git a/requirements.txt b/requirements.txt
index 81f00ef..5fb918e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,56 +1,15 @@
1aiofiles==25.1.0 1aiofiles==25.1.0
2aiohappyeyeballs==2.6.1 2aiohttp==3.13.5
3aiohttp==3.13.3
4aiosignal==1.4.0
5anyio==4.12.1
6attrs==25.4.0
7basedpyright==1.39.2
8beautifulsoup4==4.14.3 3beautifulsoup4==4.14.3
9brotli==1.2.0
10bs4==0.0.2
11certifi==2026.2.25
12cffi==2.0.0
13charset-normalizer==3.4.6
14click==8.1.8
15contourpy==1.3.3
16croniter==6.2.2 4croniter==6.2.2
17cycler==0.12.1
18davey==0.1.4
19dotenv==0.9.9
20fonttools==4.62.1
21frozenlist==1.8.0
22gTTS==2.5.4 5gTTS==2.5.4
23h11==0.16.0
24httpcore==1.0.9
25httpx==0.28.1 6httpx==0.28.1
26idna==3.11
27kiwisolver==1.5.0
28matplotlib==3.10.8
29multidict==6.7.1
30mutagen==1.47.0 7mutagen==1.47.0
31nodejs-wheel-binaries==24.14.1
32numpy==2.4.4
33packaging==26.1
34pillow==12.2.0 8pillow==12.2.0
35propcache==0.4.1 9py-cord==2.8.0
36py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord
37pycparser==3.0
38pycryptodomex==3.23.0
39PyNaCl==1.6.2
40pyparsing==3.3.2
41python-dateutil==2.9.0.post0
42python-dotenv==1.2.2 10python-dotenv==1.2.2
43pytube==15.0.0
44pyzbar==0.1.9 11pyzbar==0.1.9
45qrcode==8.2 12qrcode==8.2
46RapidFuzz==3.14.3 13requests==2.34.2
47requests==2.32.5
48six==1.17.0
49soupsieve==2.8.3
50typing_extensions==4.15.0
51urllib3==2.6.3
52websockets==16.0
53yarl==1.23.0
54youtube-search-python==1.6.6
55yt-dlp==2026.3.17 14yt-dlp==2026.3.17
56yt-dlp-ejs==0.8.0 15youtube-search-python==1.6.6
diff --git a/scripts/bump_reqs.py b/scripts/bump_reqs.py
new file mode 100644
index 0000000..5d253e7
--- /dev/null
+++ b/scripts/bump_reqs.py
@@ -0,0 +1,50 @@
1#!/usr/bin/env python3
2"""Bump all pinned versions in requirements*.txt to latest from PyPI."""
3
4import json
5import re
6import urllib.request
7from pathlib import Path
8
9REQS = sorted(Path().glob("requirements*.txt"))
10PYPI = "https://pypi.org/pypi/{}/json"
11
12
13def latest_version(package: str) -> str | None:
14 url = PYPI.format(package)
15 try:
16 with urllib.request.urlopen(url, timeout=10) as resp:
17 data = json.load(resp)
18 return data["info"]["version"]
19 except Exception:
20 return None
21
22
23def bump_file(path: Path):
24 lines = path.read_text().splitlines()
25 changed = []
26 for line in lines:
27 m = re.match(r"^([\w-]+(?:\[[\w-]+])?)==[\w.]+$", line)
28 if m:
29 pkg = m.group(1)
30 latest = latest_version(pkg)
31 if latest:
32 changed.append(f"{pkg}=={latest}")
33 if f"{pkg}=={latest}" != line:
34 print(f" {pkg}: {line.split('==')[1]} -> {latest}")
35 else:
36 changed.append(line)
37 print(f" {pkg}: failed to fetch, keeping current")
38 else:
39 changed.append(line)
40 path.write_text("\n".join(changed) + "\n")
41
42
43def main():
44 for req in REQS:
45 print(f"\n{req}:")
46 bump_file(req)
47
48
49if __name__ == "__main__":
50 main()
diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py
index 705f1b3..98c6f6b 100644
--- a/tests/cogs/test_music_audio.py
+++ b/tests/cogs/test_music_audio.py
@@ -6,7 +6,7 @@ from conftest import MockApplicationContext
6 6
7@pytest.mark.asyncio 7@pytest.mark.asyncio
8async def test_validate_voice_channel_not_in_voice(): 8async def test_validate_voice_channel_not_in_voice():
9 from cogs.music.audio_playback import AudioPlaybackManager 9 from cogs.voice import VoiceManager as AudioPlaybackManager
10 10
11 mock_ctx = MagicMock() 11 mock_ctx = MagicMock()
12 mock_ctx.author.voice = None 12 mock_ctx.author.voice = None
@@ -20,7 +20,7 @@ async def test_validate_voice_channel_not_in_voice():
20 20
21@pytest.mark.asyncio 21@pytest.mark.asyncio
22async def test_validate_voice_channel_in_voice(): 22async def test_validate_voice_channel_in_voice():
23 from cogs.music.audio_playback import AudioPlaybackManager 23 from cogs.voice import VoiceManager as AudioPlaybackManager
24 24
25 mock_channel = MagicMock() 25 mock_channel = MagicMock()
26 mock_ctx = MagicMock() 26 mock_ctx = MagicMock()
@@ -35,7 +35,7 @@ async def test_validate_voice_channel_in_voice():
35 35
36@pytest.mark.asyncio 36@pytest.mark.asyncio
37async def test_prepare_file_source_file_not_found(): 37async def test_prepare_file_source_file_not_found():
38 from cogs.music.audio_playback import AudioPlaybackManager 38 from cogs.voice import VoiceManager as AudioPlaybackManager
39 39
40 display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") 40 display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3")
41 41
@@ -45,7 +45,7 @@ async def test_prepare_file_source_file_not_found():
45 45
46@pytest.mark.asyncio 46@pytest.mark.asyncio
47async def test_prepare_file_source_absolute_path(): 47async def test_prepare_file_source_absolute_path():
48 from cogs.music.audio_playback import AudioPlaybackManager 48 from cogs.voice import VoiceManager as AudioPlaybackManager
49 49
50 with patch('os.path.exists', return_value=True): 50 with patch('os.path.exists', return_value=True):
51 with patch('os.path.isabs', return_value=True): 51 with patch('os.path.isabs', return_value=True):
@@ -57,9 +57,9 @@ async def test_prepare_file_source_absolute_path():
57 57
58@pytest.mark.asyncio 58@pytest.mark.asyncio
59async def test_prepare_source_url(): 59async def test_prepare_source_url():
60 from cogs.music.audio_playback import AudioPlaybackManager 60 from cogs.voice import VoiceManager as AudioPlaybackManager
61 61
62 source, _, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "https://example.com/stream") 62 source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream")
63 63
64 assert is_url == True 64 assert is_url == True
65 assert source == "https://example.com/stream" 65 assert source == "https://example.com/stream"
@@ -67,10 +67,10 @@ async def test_prepare_source_url():
67 67
68@pytest.mark.asyncio 68@pytest.mark.asyncio
69async def test_prepare_source_local_file(): 69async def test_prepare_source_local_file():
70 from cogs.music.audio_playback import AudioPlaybackManager 70 from cogs.voice import VoiceManager as AudioPlaybackManager
71 71
72 with patch('os.path.exists', return_value=True): 72 with patch('os.path.exists', return_value=True):
73 source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "/tmp/test.mp3") 73 source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3")
74 74
75 assert is_url == False 75 assert is_url == False
76 assert display_name == "test" 76 assert display_name == "test"
@@ -78,7 +78,7 @@ async def test_prepare_source_local_file():
78 78
79@pytest.mark.asyncio 79@pytest.mark.asyncio
80async def test_cleanup_temp_file(): 80async def test_cleanup_temp_file():
81 from cogs.music.audio_playback import AudioPlaybackManager 81 from cogs.voice import VoiceManager as AudioPlaybackManager
82 82
83 with patch('os.path.exists', return_value=True): 83 with patch('os.path.exists', return_value=True):
84 with patch('os.remove'): 84 with patch('os.remove'):
@@ -87,7 +87,7 @@ async def test_cleanup_temp_file():
87 87
88@pytest.mark.asyncio 88@pytest.mark.asyncio
89async def test_cleanup_temp_file_not_exists(): 89async def test_cleanup_temp_file_not_exists():
90 from cogs.music.audio_playback import AudioPlaybackManager 90 from cogs.voice import VoiceManager as AudioPlaybackManager
91 91
92 with patch('os.path.exists', return_value=False): 92 with patch('os.path.exists', return_value=False):
93 await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") 93 await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3")
diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py
index 1c0260a..692e1c1 100644
--- a/tests/cogs/test_music_integration.py
+++ b/tests/cogs/test_music_integration.py
@@ -27,18 +27,20 @@ async def test_music_join_stop(music_cog, bot):
27 ctx.author.voice.channel = MagicMock() 27 ctx.author.voice.channel = MagicMock()
28 ctx.guild = MagicMock() 28 ctx.guild = MagicMock()
29 29
30 voice_client = AsyncMock()
31 ctx.voice_client = voice_client
32
33 # Test join 30 # Test join
34 ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client) 31 mock_vm = MagicMock()
35 with patch.object(ctx, 'respond', new_callable=AsyncMock): 32 mock_vm.connect = AsyncMock()
36 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): 33 with patch('cogs.music.join.get_voice_manager', return_value=mock_vm):
37 await join_cog.join.callback(join_cog, ctx) 34 with patch.object(ctx, 'respond', new_callable=AsyncMock):
38 ctx.author.voice.channel.connect.assert_called_once() 35 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
36 await join_cog.join.callback(join_cog, ctx)
37 mock_vm.connect.assert_called_once_with(ctx.author.voice.channel)
39 38
40 # Test stop 39 # Test stop
41 with patch.object(ctx, 'respond', new_callable=AsyncMock): 40 mock_vm_stop = MagicMock()
42 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): 41 mock_vm_stop.disconnect = AsyncMock()
43 await stop_cog.stop.callback(stop_cog, ctx) 42 with patch('cogs.music.stop.get_voice_manager', return_value=mock_vm_stop):
44 voice_client.disconnect.assert_called_once() 43 with patch.object(ctx, 'respond', new_callable=AsyncMock):
44 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
45 await stop_cog.stop.callback(stop_cog, ctx)
46 mock_vm_stop.disconnect.assert_called_once_with(ctx.guild)
diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py
index 0176759..b40d93a 100644
--- a/tests/cogs/test_music_join.py
+++ b/tests/cogs/test_music_join.py
@@ -24,16 +24,18 @@ async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, moc
24 ctx.author = mock_user 24 ctx.author = mock_user
25 25
26 mock_voice_channel = MagicMock() 26 mock_voice_channel = MagicMock()
27 mock_voice = MagicMock()
28 mock_voice_channel.connect = AsyncMock(return_value=mock_voice)
29
30 ctx.author.voice = MagicMock() 27 ctx.author.voice = MagicMock()
31 ctx.author.voice.channel = mock_voice_channel 28 ctx.author.voice.channel = mock_voice_channel
32 29
33 with patch.object(mock_voice_channel, 'connect', new_callable=AsyncMock, return_value=mock_voice): 30 with patch('cogs.music.join.get_voice_manager') as mock_get_vm:
31 mock_vm = MagicMock()
32 mock_vm.connect = AsyncMock()
33 mock_get_vm.return_value = mock_vm
34
34 with patch.object(ctx, 'respond', new_callable=AsyncMock): 35 with patch.object(ctx, 'respond', new_callable=AsyncMock):
35 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): 36 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
36 await join_cog.join.callback(join_cog, ctx) 37 await join_cog.join.callback(join_cog, ctx)
38 mock_vm.connect.assert_called_once_with(mock_voice_channel)
37 39
38 40
39@pytest.mark.asyncio 41@pytest.mark.asyncio
@@ -42,12 +44,13 @@ async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, moc
42 ctx.user = mock_user 44 ctx.user = mock_user
43 ctx.author = mock_user 45 ctx.author = mock_user
44 46
45 mock_voice_client = MagicMock() 47 with patch('cogs.music.stop.get_voice_manager') as mock_get_vm:
46 mock_voice_client.disconnect = AsyncMock() 48 mock_vm = MagicMock()
47 ctx.voice_client = mock_voice_client 49 mock_vm.disconnect = AsyncMock()
50 mock_get_vm.return_value = mock_vm
48 51
49 with patch.object(ctx, 'respond', new_callable=AsyncMock): 52 with patch.object(ctx, 'respond', new_callable=AsyncMock):
50 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): 53 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
51 await stop_cog.stop.callback(stop_cog, ctx) 54 await stop_cog.stop.callback(stop_cog, ctx)
52 55
53 mock_voice_client.disconnect.assert_called_once() 56 mock_vm.disconnect.assert_called_once_with(mock_guild)
diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py
index 104eea6..6eb226b 100644
--- a/tests/cogs/test_voice.py
+++ b/tests/cogs/test_voice.py
@@ -53,7 +53,7 @@ async def test_voice_manager_get_voice_status_not_connected():
53 mock_bot = MagicMock() 53 mock_bot = MagicMock()
54 mock_guild = MagicMock() 54 mock_guild = MagicMock()
55 55
56 with patch('cogs.voice.VoiceManager.get_voice_client', return_value=None): 56 with patch('cogs.voice.VoiceManager.get_client', return_value=None):
57 manager = VoiceManager(mock_bot) 57 manager = VoiceManager(mock_bot)
58 voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) 58 voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild)
59 59
@@ -71,7 +71,7 @@ async def test_voice_manager_stop():
71 mock_voice_client = MagicMock() 71 mock_voice_client = MagicMock()
72 mock_voice_client.disconnect = AsyncMock() 72 mock_voice_client.disconnect = AsyncMock()
73 73
74 with patch('cogs.voice.VoiceManager.get_voice_client', return_value=mock_voice_client): 74 with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client):
75 manager = VoiceManager(mock_bot) 75 manager = VoiceManager(mock_bot)
76 await manager.stop(mock_guild) 76 await manager.stop(mock_guild)
77 77