From fbdfad855ab9a1233e286806439397729ba21226 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 20 May 2026 15:36:17 +0200 Subject: Fold AudioPlaybackManager into VoiceManager + Future-based play - Move all static helpers (validate_voice_channel, prepare_source, prepare_audio_source, etc.) from AudioPlaybackManager into VoiceManager - Add _play_await() using asyncio.Future + after callback, replacing all polling patterns and the 5-second sleep hack - Add play() high-level method and play_source() for pre-made sources - Unify connection logic under single _ensure_voice_client() - Remove dead Voice cog class and its setup() - Gut audio_playback.py to a re-export shim - Remove cogs.voice from main.py extensions --- cogs/voice.py | 407 ++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 254 insertions(+), 153 deletions(-) (limited to 'cogs/voice.py') diff --git a/cogs/voice.py b/cogs/voice.py index 6f9a269..ba1993e 100644 --- a/cogs/voice.py +++ b/cogs/voice.py @@ -4,12 +4,15 @@ # https://codeberg.org/salmonik/senator-sperma import discord -from discord.ext import commands import logging import asyncio +import os +import urllib.parse from dataclasses import dataclass, field from typing import Any +import mutagen + logger = logging.getLogger(__name__) @@ -25,11 +28,7 @@ def get_voice_manager(bot) -> "VoiceManager": def is_voice_client_alive(voice_client, guild): - """Check if a voice client connection is healthy. - - Verifies the client is connected, the channel still exists, - and the underlying voice socket is still open. - """ + """Check if a voice client connection is healthy.""" if voice_client is None: return False if not voice_client.is_connected(): @@ -52,66 +51,75 @@ def is_voice_client_alive(voice_client, guild): @dataclass class VoiceManager: - bot: Any # type: ignore[no-any-import] - _voice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) + bot: Any + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) _connecting: set[int] = field(default_factory=set, init=False) - async def join_channel(self, channel): - """Join a voice channel by channel object.""" - async with self._voice_lock: - guild = channel.guild - guild_id = guild.id + # --- Connection ----------------------------------------------------------- + + async def _ensure_voice_client(self, channel): + """Unified connect/move/reconnect logic. + + Returns a healthy VoiceClient connected to *channel*, or None on failure. + """ + guild = channel.guild + guild_id = guild.id + async with self._lock: if guild_id in self._connecting: logger.info(f"Connection already in progress for guild {guild_id}, skipping") return None - voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) + vc = discord.utils.get(self.bot.voice_clients, guild=guild) - if voice_client and voice_client.is_connected(): - if voice_client.channel == channel: - if is_voice_client_alive(voice_client, guild): - logger.info(f"Already connected to {channel}") - return voice_client - logger.warning(f"Stale connection to {channel}, reconnecting") - try: - await voice_client.disconnect() - except Exception: - pass - else: - try: - await voice_client.move_to(channel) - logger.info(f"Moved to voice channel: {channel}") - return voice_client - except Exception as e: - logger.error(f"Failed to move voice client: {e}") - logger.info("Disconnecting after failed move attempt") + # --- resolve existing client -------------------------------------- + if vc is not None: + if vc.is_connected(): + if vc.channel == channel: + if is_voice_client_alive(vc, guild): + logger.info(f"Already connected to {channel}") + return vc + logger.warning(f"Stale connection to {channel}, reconnecting") try: - await voice_client.disconnect() + await vc.disconnect() except Exception: pass - elif voice_client and not voice_client.is_connected(): - logger.info("Voice client exists but disconnected, cleaning up stale entry") - try: - await voice_client.disconnect() - except Exception: - pass - voice_client = None + vc = None + else: + try: + await vc.move_to(channel) + logger.info(f"Moved to voice channel: {channel}") + return vc + except Exception as e: + logger.error(f"Failed to move voice client: {e}") + try: + await vc.disconnect() + except Exception: + pass + vc = None + else: + logger.info("Voice client exists but disconnected, cleaning up stale entry") + try: + await vc.disconnect() + except Exception: + pass + vc = None + # --- create fresh connection -------------------------------------- self._connecting.add(guild_id) try: await asyncio.sleep(0.3) try: - voice_client = await channel.connect(timeout=30.0, reconnect=False) + vc = await channel.connect(timeout=30.0, reconnect=False) logger.info(f"Connected to voice channel: {channel}") - return voice_client + return vc except discord.ClientException as e: if "Already connected" in str(e): - logger.warning(f"Stale voice state detected, forcing disconnect and reconnect: {e}") - voice_client = discord.utils.get(self.bot.voice_clients, guild=guild) - if voice_client: + logger.warning(f"Stale voice state detected, forcing reconnect: {e}") + vc = discord.utils.get(self.bot.voice_clients, guild=guild) + if vc: try: - await voice_client.disconnect() + await vc.disconnect() except Exception: pass else: @@ -121,9 +129,9 @@ class VoiceManager: pass await asyncio.sleep(0.3) try: - voice_client = await channel.connect(timeout=30.0, reconnect=False) + vc = await channel.connect(timeout=30.0, reconnect=False) logger.info(f"Reconnected to voice channel: {channel}") - return voice_client + return vc except Exception as e2: logger.error(f"Failed to reconnect after stale cleanup: {e2}") return None @@ -135,144 +143,237 @@ class VoiceManager: finally: self._connecting.discard(guild_id) - 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.join_channel(channel) + async def connect(self, channel): + """Join a voice channel (public alias).""" + return await self._ensure_voice_client(channel) - async def get_voice_client(self, guild): + async def join_channel(self, channel): + """Backward-compatible alias for connect().""" + 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) - async def get_voice_status(self, guild): - """Get voice client status including connection and playback state. + async def get_voice_client(self, guild): + """Backward-compatible alias for get_client().""" + return await self.get_client(guild) + + async def get_status(self, guild): + """Get voice client status. Returns: - tuple: (voice_client, is_connected, is_playing) or (None, False, False) if no client + tuple: (voice_client, is_connected, is_playing) or (None, False, False) """ - voice_client = await self.get_voice_client(guild) - if voice_client is None: + vc = await self.get_client(guild) + if vc is None: return None, False, False + return vc, vc.is_connected(), vc.is_playing() - is_connected = voice_client.is_connected() - is_playing = voice_client.is_playing() if is_connected else False + async def get_voice_status(self, guild): + """Backward-compatible alias for get_status().""" + return await self.get_status(guild) + + async def disconnect(self, guild): + """Stop playback and disconnect from voice.""" + vc = await self.get_client(guild) + if vc: + vc.stop() + logger.info("Disconnecting from voice") + await vc.disconnect() + logger.info(f"Disconnected from guild {guild}") - return voice_client, is_connected, is_playing + async def stop(self, guild): + """Backward-compatible alias for disconnect().""" + await self.disconnect(guild) - async def play_audio(self, channel, audio_source, volume=1.0): - """Play audio in a voice channel.""" - voice_client = await self.join_channel(channel) - if not voice_client: - return False + # --- Playback ------------------------------------------------------------ - if abs(volume - 1.0) > 1e-9: - audio_source = discord.PCMVolumeTransformer(audio_source) - audio_source.volume = volume + async def _play_await(self, vc, audio_source): + """Play an audio source and wait for completion using an asyncio.Future. + + Returns True on success, False on error. No polling, no magic sleeps. + """ + loop = asyncio.get_event_loop() + future = loop.create_future() + + def after(error): + if not future.done(): + if error: + future.set_exception(Exception(str(error))) + else: + future.set_result(True) + + vc.play(audio_source, after=after) try: - voice_client.play(audio_source) - while voice_client.is_playing(): - await asyncio.sleep(0.1) + await future return True except Exception as e: - logger.error(f"Error playing audio: {e}") + logger.error(f"Playback error: {e}") return False - async def stop(self, guild): - """Stop playing and disconnect from voice.""" - voice_client = await self.get_voice_client(guild) - if voice_client: - voice_client.stop() - logger.info("stop() called - disconnecting from voice") - await voice_client.disconnect() - logger.info(f"Stopped and disconnected from guild {guild}") + async def play_source(self, channel, audio_source, interrupt=True, volume=1.0): + """Connect to *channel* and play a pre-made audio source. + + Returns True on success, False on failure. + """ + vc = await self.connect(channel) + if not vc: + logger.error("play_source: could not connect to voice channel") + return False + if interrupt and (vc.is_playing() or vc.is_paused()): + vc.stop() -class Voice(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._voice_lock = asyncio.Lock() + if abs(volume - 1.0) > 1e-9: + audio_source = discord.PCMVolumeTransformer(audio_source) + audio_source.volume = volume - async def _validate_user_voice_state(self, ctx): - if not ctx.author.voice or not ctx.author.voice.channel: - await ctx.send("You need to be in a voice channel first!") - return False - return True + logger.info("Playing via play_source") + return await self._play_await(vc, audio_source) - async def _create_voice_client(self, ctx): - async with self._voice_lock: - try: - await asyncio.sleep(0.5) - voice_client = await ctx.author.voice.channel.connect(timeout=30.0, reconnect=False) - logger.info(f"Connected to voice channel: {ctx.author.voice.channel}") - return voice_client - except asyncio.TimeoutError: - await ctx.send("Connection to voice channel timed out.") - return None - except discord.ClientException as e: - logger.error(f"Failed to connect to voice channel: {e}") - await ctx.send("Failed to connect to voice channel. Please try again.", ephemeral=True) - return None + # --- Source preparation (folded from AudioPlaybackManager) --------------- - async def _move_voice_client(self, voice_client, ctx): - try: - await voice_client.move_to(ctx.author.voice.channel) - logger.info(f"Moved to voice channel: {ctx.author.voice.channel}") - return voice_client - except asyncio.TimeoutError: - await ctx.send("Connection to voice channel timed out.") - return None - except discord.ClientException as e: - logger.error(f"Failed to move to voice channel: {e}") - await ctx.send("Failed to move to your voice channel. Please try again.", ephemeral=True) + @staticmethod + async def validate_voice_channel(ctx): + """Validate user is in a voice channel. Returns the channel or None.""" + if not ctx.author.voice or not ctx.author.voice.channel: + await ctx.respond( + "Musisz być na kanale głosowym, żebym mógł dołączyć!", ephemeral=True + ) return None + return ctx.author.voice.channel + + @staticmethod + async def prepare_file_source(source): + """Validate a local file and return (display_name, absolute_path) or (None, None).""" + logger.info(f"Attempting to play local file: {source}") + + if not os.path.isabs(source): + source = os.path.abspath(source) + logger.debug(f"Converted to absolute path: {source}") + + if not os.path.exists(source): + logger.error(f"File not found: {source}") + return None, None + + filename = os.path.basename(source) + display_name = os.path.splitext(filename)[0] + return display_name, source - async def _verify_connection(self, voice_client): - if not voice_client.is_connected(): + @staticmethod + async def prepare_audio_source(source, is_url, display_name): + """Prepare FFmpegPCMAudio from a URL or local file. + + Returns (audio_source, final_display_name). + """ + if is_url: + before_options = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" + audio_source = discord.FFmpegPCMAudio(source, before_options=before_options) + filename = os.path.basename(source.split("?")[0]) + filename = urllib.parse.unquote(filename) + clean_filename = os.path.splitext(filename)[0] + return audio_source, clean_filename + else: + audio_source = discord.FFmpegPCMAudio(source=source) try: - await voice_client.connect(reconnect=False, timeout=10.0) - return voice_client + metadata = mutagen.open(source) + if metadata: + title = ( + metadata.get("title", [None])[0] + if hasattr(metadata, "get") + else None + ) + if title: + display_name = title + logger.debug(f"Read metadata - title: {title}") + else: + display_name = os.path.splitext(os.path.basename(source))[0] except Exception as e: - logger.error(f"Failed to reconnect to voice: {e}") - return None - return voice_client + logger.warning(f"Could not read metadata from {source}: {str(e)}") + display_name = os.path.splitext(os.path.basename(source))[0] - async def ensure_voice_client(self, ctx): - if not await self._validate_user_voice_state(ctx): - return None + return audio_source, display_name - voice_client = discord.utils.get(self.bot.voice_clients, guild=ctx.guild) + @staticmethod + async def prepare_source(source): + """Determine whether *source* is a URL or local file. - if voice_client and not voice_client.is_connected(): + Returns (source_path, display_name, is_url) or (None, None, None) on failure. + """ + is_url = source.startswith(("http://", "https://")) + + if is_url: + logger.info(f"Attempting to play stream from URL: {source}") + return source, source, is_url + + display_name, absolute_source = await VoiceManager.prepare_file_source(source) + if not display_name: + return None, None, None + + return absolute_source, display_name, is_url + + @staticmethod + async def cleanup_temp_file(source): + """Remove a temporary file if it exists.""" + if source and os.path.exists(source): try: - logger.info("Voice client exists but not connected - disconnecting stale client") - await voice_client.disconnect() - except Exception: - pass - voice_client = None - - if not voice_client or not voice_client.is_connected(): - if not await self._validate_user_voice_state(ctx): - return None - return await self._create_voice_client(ctx) + os.remove(source) + logger.info(f"Cleaned up temporary file: {source}") + except Exception as e: + logger.error(f"Error cleaning up file {source}: {str(e)}") - if voice_client.channel != ctx.author.voice.channel: - return await self._move_voice_client(voice_client, ctx) + @staticmethod + async def cleanup_if_needed(source, delete_after_play, is_url): + """Conditionally clean up a file after playback.""" + if delete_after_play and not is_url: + await VoiceManager.cleanup_temp_file(source) - return await self._verify_connection(voice_client) + # --- High-level play (full flow) ----------------------------------------- - async def connect(self, ctx): - voice_client = await self.ensure_voice_client(ctx) - if not voice_client: - return None - return voice_client + async def play(self, ctx, source, delete_after_play=False, interrupt=True): + """High-level playback: validate context, prepare source, connect, play, cleanup. + + Args: + ctx: Discord application context (used for channel resolution & error messages). + source: Local file path or URL to stream. + delete_after_play: Remove local file after playback (no-op for URLs). + interrupt: Stop currently playing audio before starting. + + Returns: + bool: True if playback completed successfully, False otherwise. + """ + prepared_source, display_name, is_url = await VoiceManager.prepare_source(source) + if not prepared_source: + await VoiceManager.cleanup_if_needed(source, delete_after_play, False) + return False + + target_channel = await VoiceManager.validate_voice_channel(ctx) + if not target_channel: + await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) + return False + + vc = await self.connect(target_channel) + if not vc: + await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) + return False + + if interrupt and (vc.is_playing() or vc.is_paused()): + vc.stop() + + audio_source, final_display_name = await VoiceManager.prepare_audio_source( + prepared_source, is_url, display_name + ) + + 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: + await VoiceManager.cleanup_if_needed(prepared_source, delete_after_play, is_url) -def setup(bot): - bot.add_cog(Voice(bot)) + return success -- cgit v1.2.3