From 1f37a67c53233dc8e814d22620e1ff8a6fdb14b6 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 20 May 2026 15:21:37 +0200 Subject: health checks for vc Signed-off-by: salmonik --- cogs/vclogs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'cogs/vclogs.py') diff --git a/cogs/vclogs.py b/cogs/vclogs.py index e5ca202..b538265 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -129,6 +129,16 @@ class VcLogs(commands.Cog): is_connected = voice_status[1] if voice_status else False is_playing = voice_status[2] if voice_status else False + if is_connected and not is_playing: + from cogs.voice import is_voice_client_alive + if not is_voice_client_alive(voice_client, channel.guild): + logger.warning("Stale voice client in play_tts, reconnecting") + try: + await voice_client.disconnect() + except Exception: + pass + is_connected = False + if not is_connected: voice_client = await voice_mgr.join_channel(channel) voice_status = await voice_mgr.get_voice_status(channel.guild) -- cgit v1.2.3 From 4663f260b55de0975661fda1c00f60d2ca6e4506 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 20 May 2026 15:40:18 +0200 Subject: Clean up consumers: replace polling with VoiceManager.play_source() - cron.py: replace while-is_playing polling with VoiceManager.play_source() - vclogs.py: replace while-is_playing polling with VoiceManager.play_source() - stop.py: use VoiceManager.disconnect() instead of raw voice_client - join.py: use VoiceManager.connect() instead of raw channel.connect() - voice.py: add back join_by_channel_id() convenience method - Tests: update for new VoiceManager method names and signatures --- cogs/cron.py | 26 ++++++++++---------------- cogs/music/join.py | 4 +++- cogs/music/stop.py | 5 ++++- cogs/vclogs.py | 7 +------ cogs/voice.py | 11 +++++++++++ tests/cogs/test_music_audio.py | 4 ++-- tests/cogs/test_music_join.py | 25 ++++++++++++++----------- tests/cogs/test_voice.py | 4 ++-- 8 files changed, 47 insertions(+), 39 deletions(-) (limited to 'cogs/vclogs.py') 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): if isinstance(channel, discord.VoiceChannel): voice_mgr = get_voice_manager(self.bot) - voice_client = await voice_mgr.join_channel(channel) - if not voice_client: - if channel.guild.system_channel: - await channel.guild.system_channel.send( - "Nie udano się połączyć z kanałem głosowym" - ) - return try: audio_source = discord.FFmpegPCMAudio(audio_url) - voice_client.play(audio_source) + success = await voice_mgr.play_source(channel, audio_source) - while voice_client.is_playing(): - await asyncio.sleep(1) + if success: + logger.info(f"Finished playing audio: {audio_url}") + else: + raise Exception("Playback failed") - logger.info("Audio playback finished normally - disconnecting") - await voice_client.disconnect() - logger.info(f"Finished playing audio: {audio_url}") + await voice_mgr.disconnect(channel.guild) except Exception as e: logger.error(f"Error playing audio: {e}") - if voice_client.is_connected(): - logger.info("Audio playback failed - disconnecting") - await voice_client.disconnect() + try: + await voice_mgr.disconnect(channel.guild) + except Exception: + pass if channel.guild.system_channel: await channel.guild.system_channel.send( f"Błąd podczas odgrywania audio: {audio_url}" 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 @@ import logging from discord.ext import commands +from cogs.voice import get_voice_manager logger = logging.getLogger(__name__) @@ -16,8 +17,9 @@ class Join(commands.Cog): @commands.slash_command(name="join", description="dołącza do kanalu") async def join(self, ctx): + voice_mgr = get_voice_manager(self.bot) channel = ctx.author.voice.channel - await channel.connect() + await voice_mgr.connect(channel) await ctx.respond(content="\u200b", ephemeral=True) await ctx.interaction.delete_original_response() logger.info("Bot connected to voice channel") 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 @@ from discord.ext import commands +from cogs.voice import get_voice_manager + import logging logger = logging.getLogger(__name__) @@ -18,7 +20,8 @@ class Stop(commands.Cog): async def stop(self, ctx): await ctx.respond(content="\u200b", ephemeral=True) await ctx.interaction.delete_original_response() - await ctx.voice_client.disconnect() + voice_mgr = get_voice_manager(self.bot) + await voice_mgr.disconnect(ctx.guild) logger.info("Bot disconnected from voice channel") diff --git a/cogs/vclogs.py b/cogs/vclogs.py index b538265..2e853ad 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -160,12 +160,7 @@ class VcLogs(commands.Cog): logger.info("preparing to play audio") audio_source = discord.FFmpegPCMAudio(tts_file) - if volume != 1.0: - audio_source = discord.PCMVolumeTransformer(audio_source) - audio_source.volume = volume - voice_client.play(audio_source) - while voice_client.is_playing(): - await asyncio.sleep(0.1) + await voice_mgr.play_source(channel, audio_source, volume=volume) except Exception as e: logger.error(f"TTS playback error: {e}") finally: diff --git a/cogs/voice.py b/cogs/voice.py index ba1993e..86416ef 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -151,6 +151,17 @@ class VoiceManager: """Backward-compatible alias for connect().""" return await self.connect(channel) + async def join_by_channel_id(self, channel_id): + """Join a voice channel by channel ID.""" + channel = self.bot.get_channel(channel_id) + if not channel: + logger.error(f"Channel {channel_id} not found") + return None + if not isinstance(channel, discord.VoiceChannel): + logger.error(f"Channel {channel_id} is not a voice channel") + return None + return await self.connect(channel) + async def get_client(self, guild): """Get existing voice client for a guild.""" return discord.utils.get(self.bot.voice_clients, guild=guild) diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py index 705f1b3..150ad5d 100644 --- a/tests/cogs/test_music_audio.py +++ b/tests/cogs/test_music_audio.py @@ -59,7 +59,7 @@ async def test_prepare_file_source_absolute_path(): async def test_prepare_source_url(): from cogs.music.audio_playback import AudioPlaybackManager - source, _, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "https://example.com/stream") + source, _, is_url = await AudioPlaybackManager.prepare_source("https://example.com/stream") assert is_url == True assert source == "https://example.com/stream" @@ -70,7 +70,7 @@ async def test_prepare_source_local_file(): from cogs.music.audio_playback import AudioPlaybackManager with patch('os.path.exists', return_value=True): - source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "/tmp/test.mp3") + source, display_name, is_url = await AudioPlaybackManager.prepare_source("/tmp/test.mp3") assert is_url == False assert display_name == "test" 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 ctx.author = mock_user mock_voice_channel = MagicMock() - mock_voice = MagicMock() - mock_voice_channel.connect = AsyncMock(return_value=mock_voice) - ctx.author.voice = MagicMock() ctx.author.voice.channel = mock_voice_channel - with patch.object(mock_voice_channel, 'connect', new_callable=AsyncMock, return_value=mock_voice): + with patch('cogs.music.join.get_voice_manager') as mock_get_vm: + mock_vm = MagicMock() + mock_vm.connect = AsyncMock() + mock_get_vm.return_value = mock_vm + with patch.object(ctx, 'respond', new_callable=AsyncMock): with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): await join_cog.join.callback(join_cog, ctx) + mock_vm.connect.assert_called_once_with(mock_voice_channel) @pytest.mark.asyncio @@ -42,12 +44,13 @@ async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, moc ctx.user = mock_user ctx.author = mock_user - mock_voice_client = MagicMock() - mock_voice_client.disconnect = AsyncMock() - ctx.voice_client = mock_voice_client + with patch('cogs.music.stop.get_voice_manager') as mock_get_vm: + mock_vm = MagicMock() + mock_vm.disconnect = AsyncMock() + mock_get_vm.return_value = mock_vm - with patch.object(ctx, 'respond', new_callable=AsyncMock): - with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): - await stop_cog.stop.callback(stop_cog, ctx) + with patch.object(ctx, 'respond', new_callable=AsyncMock): + with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): + await stop_cog.stop.callback(stop_cog, ctx) - mock_voice_client.disconnect.assert_called_once() + 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(): mock_bot = MagicMock() mock_guild = MagicMock() - with patch('cogs.voice.VoiceManager.get_voice_client', return_value=None): + with patch('cogs.voice.VoiceManager.get_client', return_value=None): manager = VoiceManager(mock_bot) voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) @@ -71,7 +71,7 @@ async def test_voice_manager_stop(): mock_voice_client = MagicMock() mock_voice_client.disconnect = AsyncMock() - with patch('cogs.voice.VoiceManager.get_voice_client', return_value=mock_voice_client): + with patch('cogs.voice.VoiceManager.get_client', return_value=mock_voice_client): manager = VoiceManager(mock_bot) await manager.stop(mock_guild) -- cgit v1.2.3 From 8d35930871db37b1009275c423ce95ed72954613 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 20 May 2026 17:08:40 +0200 Subject: Fix stale-connection reconnect hang and TTS temp-file collision - _ensure_voice_client: call _remove_voice_client() when cleaning up stale or disconnected clients so the fresh connect doesn't hang for 30s waiting for Discord's stale state to clear - Reduce connect timeout from 30s to 10s for faster failure recovery - vclogs.py: use a unique temp filename per TTS call (include timestamp) instead of relying on PID alone, preventing concurrent TTS calls from clobbering each other's files --- cogs/vclogs.py | 2 +- cogs/voice.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) (limited to 'cogs/vclogs.py') diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 2e853ad..01a428d 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -116,7 +116,7 @@ class VcLogs(commands.Cog): voice_mgr = self.get_voice_manager() temp_dir = get_temp_dir() - tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}.mp3") + tts_file = os.path.join(temp_dir, f"temp_tts_{os.getpid()}_{asyncio.get_event_loop().time()}.mp3") tts = gTTS(text=text, lang='pl') logger.info("saving file") diff --git a/cogs/voice.py b/cogs/voice.py index 86416ef..f079c6a 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -84,6 +84,12 @@ class VoiceManager: await vc.disconnect() except Exception: pass + # Forcefully clear stale internal state so the fresh + # connect doesn't hang for 30s waiting for Discord. + try: + channel._state._remove_voice_client(guild.id) + except Exception: + pass vc = None else: try: @@ -103,6 +109,10 @@ class VoiceManager: await vc.disconnect() except Exception: pass + try: + channel._state._remove_voice_client(guild.id) + except Exception: + pass vc = None # --- create fresh connection -------------------------------------- @@ -110,7 +120,7 @@ class VoiceManager: try: await asyncio.sleep(0.3) try: - vc = await channel.connect(timeout=30.0, reconnect=False) + vc = await channel.connect(timeout=10.0, reconnect=False) logger.info(f"Connected to voice channel: {channel}") return vc except discord.ClientException as e: @@ -129,7 +139,7 @@ class VoiceManager: pass await asyncio.sleep(0.3) try: - vc = await channel.connect(timeout=30.0, reconnect=False) + vc = await channel.connect(timeout=10.0, reconnect=False) logger.info(f"Reconnected to voice channel: {channel}") return vc except Exception as e2: @@ -346,6 +356,9 @@ class VoiceManager: async def play(self, ctx, source, delete_after_play=False, interrupt=True): """High-level playback: validate context, prepare source, connect, play, cleanup. + Sends ``grom: {name}`` immediately on start, and an error follow-up + if playback fails. + Args: ctx: Discord application context (used for channel resolution & error messages). source: Local file path or URL to stream. @@ -377,14 +390,23 @@ class VoiceManager: prepared_source, is_url, display_name ) + try: + await ctx.followup.send(f"grom: {final_display_name}") + except Exception as e: + logger.error(e) + success = await self._play_await(vc, audio_source) if success: logger.info(f"Playback completed: {final_display_name}") else: logger.error(f"Playback failed: {prepared_source}") - - if not success: + try: + await ctx.followup.send( + "eror: Timeout - nie udało się połączyć ze streamem", ephemeral=True + ) + except Exception: + pass await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) return success -- cgit v1.2.3 From f5af4c11645bb1ff3aa993fb9c3a29c4a01b40c3 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 20 May 2026 17:19:11 +0200 Subject: Fix zombie voice client after gateway restart - play_tts: run is_voice_client_alive() check regardless of is_playing() state. After a gateway restart, is_playing() can return True on a dead client, which previously bypassed the stale-detection guard (was: 'if is_connected and not is_playing') --- cogs/vclogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'cogs/vclogs.py') diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 01a428d..35826d4 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -129,7 +129,7 @@ class VcLogs(commands.Cog): is_connected = voice_status[1] if voice_status else False is_playing = voice_status[2] if voice_status else False - if is_connected and not is_playing: + if is_connected: from cogs.voice import is_voice_client_alive if not is_voice_client_alive(voice_client, channel.guild): logger.warning("Stale voice client in play_tts, reconnecting") -- cgit v1.2.3