From d891c6d5e1a9959ab8076fd5086b20a0a5c3c079 Mon Sep 17 00:00:00 2001 From: PiotrWieczorek98 Date: Fri, 15 Dec 2023 22:39:06 +0100 Subject: [PATCH] Migration to wavelink 3.1 --- .env.dist | 11 + requirements.txt | 30 +- src/cogs/{bot_admin.py => admin_cog.py} | 2 +- src/cogs/audio/audio_cog.py | 219 ------------ src/cogs/audio/wavelink_player.py | 327 ------------------ src/cogs/audio/wavelink_queue.py | 20 -- src/cogs/audio_cog.py | 291 ++++++++++++++++ src/cogs/{user_related.py => user_cog.py} | 2 +- .../__init__.py => exceptions/__init__,py} | 0 src/exceptions/user_exceptions.py | 11 + src/exceptions/wavelink_exceptions.py | 15 + src/main.py | 33 +- src/utils/decorators.py | 18 +- src/utils/discord_bot.py | 2 +- src/utils/endpoints.py | 2 + .../audio_player_view.py} | 325 +++++++++-------- 16 files changed, 529 insertions(+), 779 deletions(-) create mode 100644 .env.dist rename src/cogs/{bot_admin.py => admin_cog.py} (98%) delete mode 100644 src/cogs/audio/audio_cog.py delete mode 100644 src/cogs/audio/wavelink_player.py delete mode 100644 src/cogs/audio/wavelink_queue.py create mode 100644 src/cogs/audio_cog.py rename src/cogs/{user_related.py => user_cog.py} (95%) rename src/{cogs/audio/__init__.py => exceptions/__init__,py} (100%) create mode 100644 src/exceptions/user_exceptions.py create mode 100644 src/exceptions/wavelink_exceptions.py rename src/{cogs/audio/player_control_view.py => views/audio_player_view.py} (54%) diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..10620dd --- /dev/null +++ b/.env.dist @@ -0,0 +1,11 @@ +BOT_TOKEN = "" + +# Server access +SERVER_IP="" +SERVER_PORT="" +SERVER_ENDPOINT="" + +# Wavelink +WAVELINK_URL = "" +WAVELINK_PORT = "" +WAVELINK_PASSWORD = "" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 813bd26..b736fe7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,23 @@ -aiohttp==3.8.4 +aiohttp==3.9.1 aiosignal==1.3.1 -async-timeout==4.0.2 -attrs==22.2.0 -Brotli==1.0.9 -certifi==2022.12.7 -cffi==1.15.1 -charset-normalizer==3.1.0 +async-timeout==4.0.3 +attrs==23.1.0 +Brotli==1.1.0 +certifi==2023.11.17 +cffi==1.16.0 +charset-normalizer==3.3.2 discord==2.3.2 discord.py==2.3.2 -frozenlist==1.3.3 -idna==3.4 +frozenlist==1.4.1 +idna==3.6 multidict==6.0.4 -mutagen==1.46.0 +mutagen==1.47.0 pycparser==2.21 -pycryptodomex==3.17 +pycryptodomex==3.19.0 PyNaCl==1.5.0 -websockets==10.4 -yarl==1.8.2 +websockets==12.0 +yarl==1.9.2 python-dotenv==1.0.0 static-ffmpeg==2.5 -wavelink==2.6.3 -sentry_sdk==1.17.0 \ No newline at end of file +wavelink==3.1.0 +sentry-sdk==1.39.1 diff --git a/src/cogs/bot_admin.py b/src/cogs/admin_cog.py similarity index 98% rename from src/cogs/bot_admin.py rename to src/cogs/admin_cog.py index 78cb729..fee2bcc 100644 --- a/src/cogs/bot_admin.py +++ b/src/cogs/admin_cog.py @@ -13,7 +13,7 @@ from main import DiscordBot -class BotAdmin(commands.Cog): +class AdminCog(commands.Cog): """ Class used for administrative bot commands """ diff --git a/src/cogs/audio/audio_cog.py b/src/cogs/audio/audio_cog.py deleted file mode 100644 index 817c5ae..0000000 --- a/src/cogs/audio/audio_cog.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from io import BytesIO -from typing import TYPE_CHECKING - -import discord -import wavelink -from discord import app_commands -from discord.ext import commands -from wavelink import InvalidLavalinkResponse - -from utils.decorators import user_is_in_voice_channel_check -from utils.endpoints import Endpoints - -from .player_control_view import PlayerControlView -from .wavelink_player import NoTracksFound, WavelinkPlayer - -if TYPE_CHECKING: - from main import DiscordBot - - -class AudioCog(commands.Cog): - """ - Class for music commands. - self.views is dictionary that holds handle to a message with audio controls view {guild_id:view}, - """ - - def __init__(self, bot: DiscordBot) -> None: - self.bot: DiscordBot = bot - self.voice_clients: dict[int, WavelinkPlayer] = {} - self.views: dict[int, PlayerControlView] = {} - self.track_state_change: dict[int, asyncio.Event] = {} - - def __del__(self): - for voice_client in self.voice_clients.values(): - if hasattr(voice_client, '__del__'): - voice_client.__del__() - for view in self.views.values(): - if hasattr(view, '__del__'): - view.__del__() - - def init_cog(self): - """ - pupulate voice_client dictionary.\n - Run this method after discord bot finished setting up - """ - for guild in self.bot.guilds: - self.voice_clients[guild.id] = WavelinkPlayer(self.bot, guild.voice_channels[0]) - self.track_state_change[guild.id] = asyncio.Event() - - @commands.cooldown(rate=1, per=1) - @commands.guild_only() - @app_commands.command(name="play") - @user_is_in_voice_channel_check - async def play(self, interaction: discord.Interaction, search: str, force_play: bool | None) -> None: - """ - For soundboard type audio ID from list. For YouTube type url or search phrase. - """ - await interaction.response.send_message(f"Looking for {search}...") - guild_id = interaction.guild_id - voice_channel = interaction.user.voice.channel - - # Connect to vc or change vc to the one caller is in - voice_player: WavelinkPlayer = self.voice_clients[guild_id] - await voice_player.connect_and_move_to(voice_channel) - try: - tracks = await voice_player.search_and_try_playing(search, force_play=force_play) - await interaction.edit_original_response(content=f"Found \"{tracks[0].title}\".") - view = self.get_view(interaction) - await view.replace_message(voice_player) - - # Catch errors - except NoTracksFound: - await interaction.edit_original_response(content=f"Couldn't find \"{search}\".") - except SyntaxError as err: - await interaction.edit_original_response(content='No argument passed!') - logging.error(err.msg) - except IndexError as err: - await interaction.edit_original_response(content='No such number in soundboard') - logging.error(err) - except TypeError as err: - await interaction.edit_original_response(content="Type error!") - logging.error(err) - except InvalidLavalinkResponse as err: - await interaction.edit_original_response(content="InvalidLavalinkResponse!") - logging.error(err) - - @commands.cooldown(rate=1, per=1) - @commands.guild_only() - @app_commands.command(name='disconnect') - async def disconnect(self, interaction: discord.Interaction) -> None: - """ - Simple disconnect command. - """ - voice_client: WavelinkPlayer = self.voice_clients[interaction.guild_id] - if not voice_client.is_connected: - await interaction.response.send_message(content='No bot in voice channel', ephemeral=True, delete_after=3) - return - await self._remove_view_and_disconnect(voice_client=voice_client) - await interaction.response.send_message(content='Bot disconnected', ephemeral=True, delete_after=3) - - @commands.guild_only() - @app_commands.command(name="soundboard") - async def list_soundboard(self, interaction: discord.Interaction): - """ - Lists all audio files uploaded to soundboard - """ - await interaction.response.send_message("Preparing list...") - guild_soundboard = Endpoints.get_soundboard(interaction.guild_id) - if not guild_soundboard: - await interaction.edit_original_response(content='No files uploaded!') - return - - message_content = "SOUNDBOARD\n" - i = 0 - for entry in guild_soundboard: - i += 1 - message_content += f"{i}. {entry.replace('_', ' - ', 1).replace('_', ' ').capitalize().split('.mp3')[0]}\n" - - file = discord.File(fp=BytesIO(message_content.encode("utf8")), filename="soundboard.cpp") - await interaction.edit_original_response(content='', attachments=[file]) - - @commands.guild_only() - @app_commands.command(name="volume") - async def set_volume(self, interaction: discord.Interaction, value: int): - """ - Set volume. Range: 0-100 - """ - if value < 0 or value > 100: - await interaction.response.send_message("Value must be between 0 and 100", ephemeral=True, delete_after=3) - return - - voice_client: WavelinkPlayer = self.voice_clients[interaction.guild_id] - if voice_client.is_connected: - await voice_client.set_volume(value) - await interaction.response.send_message(f"Value set to {value}", delete_after=15) - else: - await interaction.response.send_message("Bot must be in voice channel", ephemeral=True, delete_after=3) - - @commands.cooldown(rate=1, per=1) - @commands.guild_only() - @app_commands.command(name="upload") - async def upload_audio(self, interaction: discord.Interaction, mp3_file: discord.Attachment): - """ - Upload audio file to soundboard - """ - await interaction.response.send_message("Processing file...") - - if not mp3_file.filename.endswith(".mp3"): - await interaction.edit_original_response(content='Audio files must have .mp3 format') - return - - mp3_file_bytes = await mp3_file.read() - result = Endpoints.upload_audio(interaction.guild_id, mp3_file.filename, mp3_file_bytes) - await interaction.edit_original_response(content=result) - - @commands.Cog.listener() - async def on_wavelink_track_end(self, payload: wavelink.TrackEventPayload) -> None: - """ - Callback function used for players to play next audio source in queue - """ - voice_client: WavelinkPlayer = payload.player - guild_id = payload.player.guild.id - - # since we let the track finish we make sure interrupted_time is cleared - if payload.reason == 'FINISHED': - await voice_client.track_finished() - view = self.views.get(guild_id) - if view: - await view.replace_message(voice_client) - # lets the task waiting for this signal continue. - await self._set_track_state_change_signal(guild_id=guild_id) - - @commands.Cog.listener() - async def on_wavelink_track_start(self, payload: wavelink.TrackEventPayload) -> None: - """ - Callback used when new track starts playing - """ - guild_id = payload.player.guild.id - # lets the task waiting for this signal continue. - await self._set_track_state_change_signal(guild_id=guild_id) - - def get_view(self, interaction: discord.Interaction) -> PlayerControlView: - """ - returns view acording to given interraction\n - if view doesnt exist it creates it and then returns it - """ - guild_id = interaction.guild_id - channel = interaction.channel - if not (view := self.views.get(guild_id, None)): - view = PlayerControlView(self.bot, channel) - self.views[guild_id] = view - return view - - async def disconnect_if_alone(self, guild_id: int, delay: int = 2): - """ - removes a view from given guild and disconnects - """ - await asyncio.sleep(delay) - voice_client: WavelinkPlayer = self.voice_clients[guild_id] - if voice_client.channel and voice_client.is_connected and len(voice_client.channel.members) == 1: - await self._remove_view_and_disconnect(voice_client) - - async def _remove_view_and_disconnect(self, voice_client: WavelinkPlayer): - guild_id = voice_client.guild.id - await voice_client.disconnect() - if view := self.views.get(guild_id): - view.remove_view() - self.views.pop(guild_id) - - async def _set_track_state_change_signal(self, *, guild_id: int, active_time: int = 1): - """ - Sets the track end signal for given guild - """ - self.track_state_change[guild_id].set() - await asyncio.sleep(active_time) - self.track_state_change[guild_id].clear() diff --git a/src/cogs/audio/wavelink_player.py b/src/cogs/audio/wavelink_player.py deleted file mode 100644 index 1d9544f..0000000 --- a/src/cogs/audio/wavelink_player.py +++ /dev/null @@ -1,327 +0,0 @@ -import asyncio -import re - -import discord -import wavelink - -from utils.discord_bot import DiscordBot -from utils.endpoints import Endpoints - -from .wavelink_queue import WavelinkQueue - - -class WavelinkPlayer(wavelink.Player): - """ - Wavelink player subclass - """ - - def __init__(self, client: DiscordBot, channel: discord.VoiceChannel) -> None: - self.history: WavelinkQueue = WavelinkQueue() - self.start_times: dict[int, int] = {} # title: time - self.interupt_times: dict[int, int] = {} # title: time - self._current_track: wavelink.Playable | None = None - super().__init__(client, channel) - self.queue: WavelinkQueue = WavelinkQueue() - - def __del__(self): - coro = self.disconnect() - asyncio.run_coroutine_threadsafe(coro, self.client.loop) - - @property - def is_connected(self) -> bool: - """ - Check if the bot is is any voice channel - """ - return bool(self.guild) - - async def connect(self, *, timeout: float, reconnect: bool, **kwargs) -> None: - voice_channel = kwargs.get("voice_channel", None) - if voice_channel is None: - raise RuntimeError('voice_channel not passed') - del kwargs["voice_channel"] - if self.channel is None: - self.channel = voice_channel - key_id, _ = self.channel._get_voice_client_key() - state = self.channel._state - if state._get_voice_client(key_id): - return - state._add_voice_client(key_id, self) - ## basicaly original method of connecting but slightly changed so it supports our method of connecting - - if not self._guild: - self._guild = self.channel.guild - - if not self.current_node._players.get(self._guild.id): - self.current_node._players[self._guild.id] = self - - await self.channel.guild.change_voice_state(channel=self.channel, **kwargs) - ## end of connect method - await self.set_filter(wavelink.Filter()) - - async def connect_and_move_to(self, voice_channel: discord.VoiceChannel): - """ - Connect if not connected, then move to the voicechannel if not already in it - """ - await self.connect(timeout=20, reconnect=True, voice_channel=voice_channel) - if self.channel.id != voice_channel.id: - await super().move_to(voice_channel) - - async def disconnect(self, **kwargs): - await self.stop_all() - await super().disconnect(**kwargs) - - async def play( - self, - track: wavelink.Playable, - replace: bool = True, - start: int | None = None, - end: int | None = None, - volume: int | None = None, - *, - populate: bool = False, - ) -> wavelink.Playable: - """ - Play a WaveLink Track. - - Parameters - ---------- - track: :class:`tracks.Playable` - The :class:`tracks.Playable` track to start playing. - replace: bool - Whether this track should replace the current track. Defaults to ``True``. - start: Optional[int] - The position to start the track at in milliseconds. - Defaults to ``None`` which will start the track at the beginning.\n - * Left to have same signature as original play however should not be used.\n - * This play gets its start_times from start_times dict\n - end: Optional[int] - The position to end the track at in milliseconds. - Defaults to ``None`` which means it will play until the end. - volume: Optional[int] - Sets the volume of the player. Must be between ``0`` and ``1000``. - Defaults to ``None`` which will not change the volume. - populate: bool - Whether to populate the AutoPlay queue. This is done automatically when AutoPlay is on. - Defaults to False. - - Returns - ------- - :class:`tracks.Playable` - The track that is now playing. - """ - - if start is not None: - raise ValueError('You should not pass start_time here. Consider using try_playing') - start = self.start_times.get(track.title, 0) - interrupted_time = self.interupt_times.get(track.title, 0) - if interrupted_time > start: - start = interrupted_time - returned_track = await super().play( - track=track, replace=replace, start=start, end=end, volume=volume, populate=populate - ) - self._paused = False # because it doesnt update if player is paused and we start playing something - self._current_track = track - return returned_track - - async def track_finished(self): - """ - Plays the next song in queue if it exists. - Also adds the finished track to history - If something is playing it raises exception. - - Since we are not using autoplay thats how we get voiceplayer to play another track. - """ - if self.is_playing(): - raise ValueError('bot is playing right now') - self.interupt_times.pop(self._current_track.title, None) - await self.history.put_wait(self._current_track) - if not self.queue.is_empty: - first_in_queue = await self.queue.get_wait() - await self.play(track=first_in_queue) - else: - self._current_track = None - - async def try_playing( - self, tracks: list[wavelink.Playable], *, start_time: int = 0, force_play: bool | None = False - ) -> None: - """ - Adds tracks to queue\n - If nothing is playing then plays the first track in queue\n - \n - When force_play is true adds track to the front of the queue then plays the first track no mather what\n - if track was playing it is moved to the history queue - """ - if force_play: - tracks.reverse() # we need to reverse list since we are using put_at_front later - for track in tracks: - self.start_times[track.title] = start_time - if force_play: - self.queue.put_at_front(track) - else: - await self.queue.put_wait(track) - - if not self.is_playing() or force_play: - first_in_queue = self.queue.get() - if self.is_playing() or self.is_paused(): - current_track = self.current - self.interupt_times[current_track.title] = self.last_position - self.queue.put_at_index(len(tracks) - 1, current_track) - await self.play(first_in_queue) - - async def play_from_queue(self, index: int, *, history: bool = False, force_play: bool = True) -> None: - """Plays the track from the queue from the given index and removes it from the queue - - Args: - index (int): place in the queue - history (bool, optional): Whether to get track from current queue or history queue. Defaults to False. - force_play (bool, optional): Whether . Defaults to True. - """ - track = self.history.pop_index(index) if history else self.queue.pop_index(index) - await self.try_playing([track], start_time=self.start_times.get(track.title, 0), force_play=force_play) - - async def search_tracks(self, search_phrase: str) -> tuple[list[wavelink.Playable], int]: - """ - Decides which type of track should be used based on search phrase - Args: - search_phrase (str): text input from discord command user - - Returns: - tuple[list[wavelink.Playable], int]: tuple with list of tracks in case of playlist with start_time = 0,\n - list with single track and start time otherwise. - - """ - start_time: int = 0 - youtube_playlist_regex = re.search(r"list=([^#\&\?]*).*", search_phrase) - # Check if user wants to play audio from YouTube Playlist... - try: - if youtube_playlist_regex and youtube_playlist_regex.groups(): - safe_url = f'https://www.youtube.com/playlist?list={youtube_playlist_regex.groups()[0]}' - playlist = await wavelink.YouTubePlaylist.search(safe_url) - tracks = playlist.tracks - # ...or soundboard... - elif search_phrase.isdecimal(): - sound_id = int(search_phrase) - guild_soundboard = Endpoints.get_soundboard(self.guild.id) - if not guild_soundboard or sound_id > len(guild_soundboard): - tracks = None - - file_name = guild_soundboard[int(search_phrase) - 1] - file_path = f'sounds/{str(self.guild.id)}/{file_name}' - track = await wavelink.GenericTrack.search(file_path) - tracks = [track[0]] - # ...Else search_phrase on youtube. - else: - # Check if start time was passed - start_time_regex = re.search(r"(?:[\?&])?t=([0-9]+)", search_phrase) - if start_time_regex and start_time_regex.groups()[0]: - start_time = int(start_time_regex.groups()[0]) * 1000 - - # We need to extract vid id because wavelink does not support shortened links - video_id_regex = re.search( - r"youtu(?:be\.com\/watch\?[^\s]*v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?", search_phrase - ) - if video_id_regex and video_id_regex.groups()[0]: - safe_url = f'https://www.youtube.com/watch?v={video_id_regex.groups()[0]}' - track = await wavelink.YouTubeTrack.search(safe_url) - tracks = [track[0]] - else: - track = await wavelink.YouTubeTrack.search(search_phrase) - tracks = [track[0]] - except wavelink.NoTracksError: - tracks = None - if not tracks: - raise NoTracksFound - - return tracks, start_time - - async def search_and_try_playing( - self, search_query: str, force_play: bool | None = False - ) -> list[wavelink.Playable]: - """ - Gets tracks from search_querry then tries to play them.\n - Returns list of found tracks - """ - tracks, start_time = await self.search_tracks(search_query) - await self.try_playing(tracks, start_time=start_time, force_play=force_play) - return tracks - - async def stop_all(self) -> None: - """ - stops currenty playing track\n - clears the queue - """ - # Clear player - self.queue.clear() - await self.stop() - - async def stop(self) -> None: - """ - stops the currently playing track and add it to history. - Does nothing if nothing is playing. - """ - - if current := self.current: - self.interupt_times[current.title] = self.last_position - await self.history.put_wait(current) - await super().stop() - - async def skip(self) -> None: - """ - Skip to next song if nothing in the queue it stops current track - """ - if current := self.current: - self.interupt_times[current.title] = self.last_position - await self.history.put_wait(current) - if not self.queue.is_empty: - next_track = await self.queue.get_wait() - await self.play(next_track) - elif self.is_playing(): - await super().stop() - - async def previous(self) -> None: - """ - plays previous track if available. - Does nothing if there is nothing in history - """ - if self.history.is_empty: - return - track: wavelink.Playable = self.history.pop() - if current := self.current: - self.interupt_times[current.title] = self.last_position - self.queue.put_at_front(current) - await self.play(track) - - async def toggle_pause(self): - """ - toggles pause on or off - """ - if not self.is_paused(): - await self.pause() - else: - await self.resume() - - async def toggle_cursed_filter(self): - """ - toggles the 4th density filter - """ - if not self.filter: - filter_ = wavelink.Filter( - tremolo=wavelink.Tremolo(frequency=4, depth=0.3), - vibrato=wavelink.Vibrato(frequency=14, depth=1), - timescale=wavelink.Timescale(pitch=0.8), - ) - else: - filter_ = wavelink.Filter() - await self.set_filter(filter_) - - -class WavelinkPlayerException(Exception): - """ - Base Exception for all WavelinkPlayer exceptions - """ - - -class NoTracksFound(WavelinkPlayerException): - """ - Exception for when WavelinkPlayer couldn't find a track - """ diff --git a/src/cogs/audio/wavelink_queue.py b/src/cogs/audio/wavelink_queue.py deleted file mode 100644 index 4f69244..0000000 --- a/src/cogs/audio/wavelink_queue.py +++ /dev/null @@ -1,20 +0,0 @@ -from wavelink import Queue, QueueEmpty - - -class WavelinkQueue(Queue): - """ - Pops item at a given index from the queue - """ - - def pop_index(self, index): - """ - Pops item from given index. - Returns the item - """ - if self.is_empty: - raise QueueEmpty - if index >= len(self): - raise ValueError('No such index in queue') - item = self[index] - del self[index] - return item diff --git a/src/cogs/audio_cog.py b/src/cogs/audio_cog.py new file mode 100644 index 0000000..17a7608 --- /dev/null +++ b/src/cogs/audio_cog.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import asyncio +import logging +from io import BytesIO +import re +from typing import TYPE_CHECKING, cast + +import discord +import wavelink +from discord import app_commands +from discord.ext import commands + +from utils.decorators import user_is_in_voice_channel_check +from utils.endpoints import Endpoints +from views.audio_player_view import AudioPlayerView +from exceptions.wavelink_exceptions import YoutubeTrackNotFound, UnexpectedPlayableType +from exceptions.user_exceptions import SoundboardTrackNotFound + +if TYPE_CHECKING: + from main import DiscordBot + + +class AudioCog(commands.Cog): + """ + Class for music commands. + self.views is dictionary that holds handle to a message with audio controls view {guild_id:view}, + """ + + def __init__(self, bot: DiscordBot) -> None: + self.bot: DiscordBot = bot + self.views: dict[int, AudioPlayerView] = {} + + def __del__(self): + for view in self.views.values(): + if hasattr(view, '__del__'): + view.__del__() + + + @commands.cooldown(rate=1, per=1) + @commands.guild_only() + @app_commands.command(name="play") + @user_is_in_voice_channel_check + async def play(self, interaction: discord.Interaction, search: str, force_play: bool | None) -> None: + """ + To use soundboard type audio ID from list. To use YouTube type url or a search phrase. + """ + await interaction.response.send_message(f"Looking for {search}...") + guild_id = interaction.guild_id + user_channel = interaction.user.voice.channel + + + # Connect to vc or change vc to the one caller is in + player = cast(wavelink.Player, interaction.guild.voice_client) + if not player or not player.connected: + player = await user_channel.connect(cls=wavelink.Player, timeout=20) + elif player.channel != user_channel: + await player.move_to(user_channel) + + view = self.views.get(guild_id, None) + if not view: + view = AudioPlayerView(self.bot, interaction.channel) + self.views[guild_id] = view + + player.autoplay = wavelink.AutoPlayMode.partial + try: + result, start_time = await self.__search_tracks(search, guild_id) + if isinstance(result, wavelink.Playlist): + await interaction.edit_original_response(content=f"Found \"{result.name}\".") + else: + await interaction.edit_original_response(content=f"Found \"{result.title}\".") + + await player.queue.put_wait(result) + if not player.playing: + await player.play(player.queue.get(), start=start_time) + await view.send_embed() + + # Catch errors + except YoutubeTrackNotFound as err: + await interaction.edit_original_response(content="Youtube track not found!") + logging.error(err) + except SoundboardTrackNotFound as err: + await interaction.edit_original_response(content="Soundboard track not found!") + logging.error(err) + except UnexpectedPlayableType as err: + await interaction.edit_original_response(content="Server returned unexpected type!") + logging.error(err) + except SyntaxError as err: + await interaction.edit_original_response(content='No argument passed!') + logging.error(err.msg) + except IndexError as err: + await interaction.edit_original_response(content='No such number in soundboard') + logging.error(err) + except TypeError as err: + await interaction.edit_original_response(content="Type error!") + logging.error(err) + + @commands.cooldown(rate=1, per=1) + @commands.guild_only() + @app_commands.command(name="skip") + @user_is_in_voice_channel_check + async def skip(self, interaction: discord.Interaction) -> None: + """ + To use soundboard type audio ID from list. To use YouTube type url or a search phrase. + """ + player = cast(wavelink.Player, interaction.guild.voice_client) + await player.skip() + + if player.queue: + await interaction.response.send_message(f"Skipped track to \"{player.current.title}\".") + else: + await interaction.response.send_message("Skipped track.") + + @commands.cooldown(rate=1, per=1) + @commands.guild_only() + @app_commands.command(name="disconnect") + async def disconnect(self, interaction: discord.Interaction) -> None: + """ + Simple disconnect command. + """ + player = cast(wavelink.Player, interaction.guild.voice_client) + if not player.connected: + await interaction.response.send_message(content='Bot is not connected to any voice channel', + ephemeral=True, + delete_after=3) + return + await self.__remove_view_and_disconnect(player) + + await interaction.response.send_message(content='Bot disconnected', ephemeral=True, delete_after=3) + + @commands.guild_only() + @app_commands.command(name="soundboard") + async def list_soundboard(self, interaction: discord.Interaction): + """ + Lists all audio files uploaded to soundboard + """ + await interaction.response.send_message("Preparing list...") + guild_soundboard = Endpoints.get_soundboard(interaction.guild_id) + if not guild_soundboard: + await interaction.edit_original_response(content='No files uploaded!') + return + + message_content = "SOUNDBOARD\n" + i = 0 + for entry in guild_soundboard: + i += 1 + message_content += f"{i}. {entry.replace('_', ' - ', 1).replace('_', ' ').capitalize().split('.mp3')[0]}\n" + + file = discord.File(fp=BytesIO(message_content.encode("utf8")), filename="soundboard.cpp") + await interaction.edit_original_response(content='', attachments=[file]) + + @commands.guild_only() + @app_commands.command(name="volume") + async def set_volume(self, interaction: discord.Interaction, value: int): + """ + Set volume. Range: 0-100 + """ + if value < 0 or value > 100: + await interaction.response.send_message("Value must be between 0 and 100", ephemeral=True, delete_after=3) + return + + player = cast(wavelink.Player, interaction.guild.voice_client) + if player.connected: + await player.set_volume(value) + await interaction.response.send_message(f"Value set to {value}", delete_after=15) + else: + await interaction.response.send_message("Bot must be in voice channel", ephemeral=True, delete_after=3) + + @commands.cooldown(rate=1, per=1) + @commands.guild_only() + @app_commands.command(name="upload") + async def upload_audio(self, interaction: discord.Interaction, mp3_file: discord.Attachment): + """ + Upload audio file to soundboard + """ + await interaction.response.send_message("Processing file...") + + if not mp3_file.filename.endswith(".mp3"): + await interaction.edit_original_response(content='Audio files must have .mp3 format') + return + + mp3_file_bytes = await mp3_file.read() + result = Endpoints.upload_audio(interaction.guild_id, mp3_file.filename, mp3_file_bytes) + await interaction.edit_original_response(content=result) + + async def __search_tracks(self, search_phrase: str, guild_id: str) \ + -> tuple[wavelink.Playable | wavelink.Playlist, int]: + """ + Decides which type of track should be used based on search phrase + Args: + search_phrase (str): text input from discord command user + + Returns: + tuple[list[wavelink.Playable], int]: tuple with list of tracks in case of playlist with start_time = 0,\n + list with single track and start time otherwise. + + """ + start_time: int = 0 + tracks = None + youtube_playlist_regex = re.search(r"list=([^#\&\?]*).*", search_phrase) + + # Check if it's a YouTube playlist... + if youtube_playlist_regex and youtube_playlist_regex.groups(): + # Build safe url to avoid errors + safe_url = f'https://www.youtube.com/playlist?list={youtube_playlist_regex.groups()[0]}' + search_result = await wavelink.Playable.search(safe_url) + if isinstance(search_result, wavelink.Playlist): + tracks = search_result + return tracks, start_time + raise UnexpectedPlayableType + + # ...or track in the soundboard... + if search_phrase.isdecimal(): + sound_id = int(search_phrase) + guild_soundboard = Endpoints.get_soundboard(guild_id) + if guild_soundboard and sound_id <= len(guild_soundboard): + file_name = guild_soundboard[sound_id - 1] + file_path = f'sounds/{guild_id}/{file_name}' + search_result = await wavelink.Pool.fetch_tracks(file_path) + tracks = search_result[0] + return tracks, start_time + raise SoundboardTrackNotFound + + # ...otherwise search given phrase on youtube + # Check if start time was passed + start_time_regex = re.search(r"(?:[\?&])?t=([0-9]+)", search_phrase) + if start_time_regex and start_time_regex.groups()[0]: + start_time = int(start_time_regex.groups()[0]) * 1000 + + # Build safe url to avoid errors + video_id_regex = re.search( + r"youtu(?:be\.com\/watch\?[^\s]*v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?", search_phrase + ) + if video_id_regex and video_id_regex.groups()[0]: + safe_url = f'https://www.youtube.com/watch?v={video_id_regex.groups()[0]}' + search_result = await wavelink.Playable.search(safe_url) + else: + search_result = await wavelink.Playable.search(search_phrase) + + tracks = search_result[0] + if not tracks: + raise YoutubeTrackNotFound + + return tracks, start_time + + async def disconnect_if_alone(self, player: discord.VoiceProtocol, delay: int = 2): + """ + removes a view from given guild and disconnects + """ + await asyncio.sleep(delay) + player: wavelink.Player = cast(wavelink.Player, player) + if player.channel and player.connected and len(player.channel.members) == 1: + await self.__remove_view_and_disconnect(player) + + async def __remove_view_and_disconnect(self, player: wavelink.Player): + guild_id = player.guild.id + await player.disconnect() + if view := self.views.get(guild_id): + view.remove_view() + self.views.pop(guild_id) + + # @commands.Cog.listener() + # async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: + # """ + # Callback used when new track starts playing + # Used to update embed wherever player changes track + # """ + # player = payload.player + # view = self.views.get(player.guild.id) + # history = player.queue.history + # # Wait for track history update (should happen instantly but sometimes it doesn't) + # while True: + # await asyncio.sleep(0.1) + # if len(history) > 0 and history[-1] == player.current: + # break + + # await view.send_embed() + + @commands.Cog.listener() + async def on_wavelink_track_end(self, payload: wavelink.TrackEndEventPayload) -> None: + """ + Callback used when player finished playing a track + Used only to update embed when all tracks finished playing since we'd use + on_wavelink_track_start otherwise + """ + player = payload.player + view = self.views.get(player.guild.id) + # Due to relying on validation from Lavalink player.playing property may in some cases + # return True directly after skipping/stopping a track, so we wait a bit + await asyncio.sleep(0.1) + await view.send_embed() diff --git a/src/cogs/user_related.py b/src/cogs/user_cog.py similarity index 95% rename from src/cogs/user_related.py rename to src/cogs/user_cog.py index dd81714..2909060 100644 --- a/src/cogs/user_related.py +++ b/src/cogs/user_cog.py @@ -10,7 +10,7 @@ from __main__ import DiscordBot -class UserRelated(commands.Cog): +class UserCog(commands.Cog): """ Class for commands related with users """ diff --git a/src/cogs/audio/__init__.py b/src/exceptions/__init__,py similarity index 100% rename from src/cogs/audio/__init__.py rename to src/exceptions/__init__,py diff --git a/src/exceptions/user_exceptions.py b/src/exceptions/user_exceptions.py new file mode 100644 index 0000000..1a1f38e --- /dev/null +++ b/src/exceptions/user_exceptions.py @@ -0,0 +1,11 @@ + +class UserException(Exception): + """ + Base Exception for all exceptions caused by user + """ + + +class SoundboardTrackNotFound(UserException): + """ + Exception for when given soundboard id was not found + """ diff --git a/src/exceptions/wavelink_exceptions.py b/src/exceptions/wavelink_exceptions.py new file mode 100644 index 0000000..0e95514 --- /dev/null +++ b/src/exceptions/wavelink_exceptions.py @@ -0,0 +1,15 @@ + +class WavelinkPlayerException(Exception): + """ + Base Exception for all WavelinkPlayer exceptions + """ + +class YoutubeTrackNotFound(WavelinkPlayerException): + """ + Exception for when WavelinkPlayer couldn't find a track + """ + +class UnexpectedPlayableType(WavelinkPlayerException): + """ + Exception for when WavelinkPlayer returned unexpected Playable type + """ diff --git a/src/main.py b/src/main.py index 78d1880..b794473 100644 --- a/src/main.py +++ b/src/main.py @@ -5,13 +5,12 @@ import sys import discord -import sentry_sdk import static_ffmpeg from dotenv import load_dotenv -from cogs.audio.audio_cog import AudioCog -from cogs.bot_admin import BotAdmin -from cogs.user_related import UserRelated +from cogs.audio_cog import AudioCog +from cogs.admin_cog import AdminCog +from cogs.user_cog import UserCog from utils.discord_bot import DiscordBot # Set up logger @@ -28,19 +27,6 @@ if not discord.opus.is_loaded(): raise RuntimeError('Opus failed to load!') -if key := os.getenv("SENTRY_KEY", None): - sentry_sdk.init( - dsn=key, - # Set tracesSampleRate to 1.0 to capture 100% - # of transactions for performance monitoring. - # We recommend adjusting this value in production - traces_sample_rate=1.0, - _experiments={ - "profiles_sample_rate": 1.0, - }, - ) - - class Bot: """ Main Bot class @@ -55,9 +41,9 @@ def create_cogs(self): """ Create Cogs """ - self.bot_admin = BotAdmin(self.bot) - self.audio_player = AudioCog(self.bot) - self.users_related = UserRelated(self.bot) + self.bot_admin = AdminCog(self.bot) + self.audio = AudioCog(self.bot) + self.users_related = UserCog(self.bot) def setup_events(self): """ @@ -70,14 +56,15 @@ async def on_ready(): Event that occurrence one time when bot is ready to work """ logging.info('Logged in as %s (ID: %d)\n-----------\n', self.bot.user, self.bot.user.id) - self.audio_player.init_cog() @self.bot.event async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): """ Event that occurrence whenever someone leaves/joins vc """ - await self.audio_player.disconnect_if_alone(member.guild.id) + player = member.guild.voice_client + if player: + await self.audio.disconnect_if_alone(player, 10) async def run(self): """ @@ -88,7 +75,7 @@ async def run(self): async with self.bot: await self.bot.add_cog(self.bot_admin) await self.bot.add_cog(self.users_related) - await self.bot.add_cog(self.audio_player) + await self.bot.add_cog(self.audio) await self.bot.start(os.getenv('BOT_TOKEN')) diff --git a/src/utils/decorators.py b/src/utils/decorators.py index 4beac18..2bde397 100644 --- a/src/utils/decorators.py +++ b/src/utils/decorators.py @@ -1,8 +1,10 @@ import asyncio import functools import inspect +from typing import cast import discord +import wavelink def bot_is_in_voice_channel_check(func): @@ -17,8 +19,8 @@ async def decorator(*args, **kwargs): if interaction is None: raise ValueError("Interaction is None") - bot_vc = interaction.guild.voice_client - if not bot_vc: + player = cast(wavelink.Player, interaction.guild.voice_client) + if not player: await interaction.response.send_message("Bot not in voice channel", delete_after=3, ephemeral=True) return await func(*args, **kwargs) @@ -38,8 +40,8 @@ async def decorator(*args, **kwargs): if interaction is None: raise ValueError("Interaction is None") - user_vc = interaction.user.voice - if not user_vc: + voice_channel = interaction.user.voice + if not voice_channel: await interaction.response.send_message( "You can't control the bot because you're not in a voice channel", delete_after=3, ephemeral=True ) @@ -62,8 +64,8 @@ async def decorator(*args, **kwargs): if interaction is None: raise ValueError("Interaction is None") - bot_vc, user_vc = interaction.guild.voice_client, interaction.user.voice - if not (bot_vc and user_vc and bot_vc.channel.id == user_vc.channel.id): + player, voice_channel = cast(wavelink.Player, interaction.guild.voice_client), interaction.user.voice + if not (player and voice_channel and player.channel.id == voice_channel.channel.id): msg = "You can't control the bot because you're not on the same voice channel" await interaction.response.send_message(msg, delete_after=3, ephemeral=True) return @@ -84,8 +86,8 @@ async def decorator(*args, **kwargs): if interaction is None: raise ValueError("Interaction is None") - bot_vc = interaction.guild.voice_client - if not bot_vc.is_playing(): + player = cast(wavelink.Player, interaction.guild.voice_client) + if not player.playing: await interaction.response.send_message("Nothing is playing right now", delete_after=3, ephemeral=True) return await func(*args, **kwargs) diff --git a/src/utils/discord_bot.py b/src/utils/discord_bot.py index 7903ae9..6f9d27f 100644 --- a/src/utils/discord_bot.py +++ b/src/utils/discord_bot.py @@ -41,7 +41,7 @@ async def setup_hook(self) -> None: node_url = f"{os.getenv('WAVELINK_URL')}:{os.getenv('WAVELINK_PORT')}" node: wavelink.Node = wavelink.Node(uri=node_url, password=os.getenv('WAVELINK_PASSWORD')) try: - await wavelink.NodePool.connect(client=self, nodes=[node]) + await wavelink.Pool.connect(client=self, nodes=[node]) except wavelink.exceptions.WavelinkException as err: logging.warning("Could not connect to lavalink!") logging.warning(err) diff --git a/src/utils/endpoints.py b/src/utils/endpoints.py index a14f6e4..da879b8 100644 --- a/src/utils/endpoints.py +++ b/src/utils/endpoints.py @@ -46,4 +46,6 @@ def upload_audio(guild_id: int, file_name: str, file_data: bytes) -> str: message = f'Upload failed, server code:{response.status_code}' except requests.exceptions.ConnectTimeout: message = 'Upload failed, request timed out.' + except requests.exceptions.ReadTimeout: + message = 'Upload failed, request timed out.' return message diff --git a/src/cogs/audio/player_control_view.py b/src/views/audio_player_view.py similarity index 54% rename from src/cogs/audio/player_control_view.py rename to src/views/audio_player_view.py index 9d97ac8..7146b07 100644 --- a/src/cogs/audio/player_control_view.py +++ b/src/views/audio_player_view.py @@ -2,8 +2,7 @@ import asyncio import datetime -import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import discord import wavelink @@ -12,15 +11,11 @@ from utils.decorators import ( button_cooldown, is_playing_check, - run_threadsafe, user_bot_in_same_channel_check, ) -from .wavelink_player import WavelinkPlayer - if TYPE_CHECKING: - from audio_cog import AudioCog - + from cogs.audio_cog import AudioCog from main import DiscordBot @@ -28,7 +23,7 @@ MAX_SELECT_LEN = 25 -class PlayerControlView(discord.ui.View): +class AudioPlayerView(discord.ui.View): """ View class for controlling audio player through view """ @@ -39,6 +34,7 @@ def __init__(self, bot: DiscordBot, text_channel: discord.TextChannel): self.text_channel: discord.TextChannel = text_channel self.message_handle: discord.Message | None = None self.queue_page: int = 0 + self.filters_applied = False self._cooldown = commands.CooldownMapping.from_cooldown(rate=1, per=1, type=commands.BucketType.channel) def __del__(self): @@ -48,19 +44,51 @@ def __del__(self): except AttributeError: pass + def remove_view(self): + """ + Removes embed with audio player information + """ + if self.message_handle: + coro = self.message_handle.delete() + self.stop() + self.clear_items() + asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + + async def send_embed(self): + """ + Removes last message and sends new one to keep it on the bottom of the chat\n + """ + embed = await self.__prepare_embed() + self._update_buttons_state() + if self.message_handle: + await self.message_handle.delete() + self.message_handle = await self.text_channel.send(content=None, embed=embed, view=self) + + @discord.ui.button(label='◀◀ Prev', style=discord.ButtonStyle.blurple, row=0) @user_bot_in_same_channel_check @button_cooldown - async def undo_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def previous_button(self, interaction: discord.Interaction, button: discord.ui.Button): """ - Undo a song skip. + Play previous track. """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.previous() - self.update_view_state(voice_client) - embed = await self.calculate_embed(voice_client) - coro = interaction.response.edit_message(view=self, embed=embed) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + queue = player.queue + history = player.queue.history + + # If player is currently playing a track then last object in history is that track + # otherwise last object in history is a previous track + if player.playing: + current_track = history[-1] + previous_track = history[-2] + queue._queue.appendleft(current_track) + await history.delete(-1) + else: + previous_track = history[-1] + + await interaction.response.defer() + await player.play(previous_track, add_history=False) + await self._update_embed() @discord.ui.button(label='❚❚ Pause', style=discord.ButtonStyle.blurple, row=0) @user_bot_in_same_channel_check @@ -69,11 +97,10 @@ async def pause_button(self, interaction: discord.Interaction, button: discord.u """ Pause/resume the player on button press """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.toggle_pause() - self.update_view_state(voice_client) - coro = interaction.response.edit_message(view=self) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + await player.pause(not player.paused) + await interaction.response.defer() + await self._update_embed() @discord.ui.button(label='▶▶ Skip', style=discord.ButtonStyle.blurple, row=0) @user_bot_in_same_channel_check @@ -83,13 +110,9 @@ async def skip_button(self, interaction: discord.Interaction, button: discord.ui """ Skip track on button press """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.skip() - await self.wait_for_track_end() - self.update_view_state(voice_client) - embed = await self.calculate_embed(voice_client) - coro = interaction.response.edit_message(view=self, embed=embed) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + await interaction.response.defer() + await player.skip() @discord.ui.button(label='▮ Stop', style=discord.ButtonStyle.red, row=0) @user_bot_in_same_channel_check @@ -99,26 +122,30 @@ async def stop_button(self, interaction: discord.Interaction, button: discord.ui """ Stop track on button press """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.stop_all() - await self.wait_for_track_end() - self.update_view_state(voice_client) - embed = await self.calculate_embed(voice_client) - coro = interaction.response.edit_message(view=self, embed=embed) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + player.queue.clear() + await player.skip() + await interaction.response.defer() @discord.ui.button(label='ඞ', style=discord.ButtonStyle.grey, row=0) @user_bot_in_same_channel_check @is_playing_check async def filter_button(self, interaction: discord.Interaction, button: discord.ui.Button): """ - fourth density + Filter to toggle audio filters """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.toggle_cursed_filter() - self.update_view_state(voice_client) - coro = interaction.response.edit_message(view=self) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + if self.filters_applied: + await player.set_filters() + else: + filters: wavelink.Filters = wavelink.Filters() + filters.timescale.set(pitch=1.2, speed=1.1, rate=1) + filters.equalizer.reset() + await player.set_filters(filters) + + self.filters_applied = not self.filters_applied + await interaction.response.defer() + await self._update_embed() @discord.ui.select( cls=discord.ui.Select, @@ -134,60 +161,99 @@ async def queue_select(self, interaction: discord.Interaction, select: discord.u """ Allows selecting song from the queue and playing it """ - voice_client: WavelinkPlayer = interaction.guild.voice_client - await voice_client.play_from_queue(index=int(select.values[0]), history=self.queue_page < 0, force_play=True) - await self.wait_for_track_end() - self.update_view_state(voice_client) - embed = await self.calculate_embed(voice_client) - coro = interaction.response.edit_message(view=self, embed=embed) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + player = cast(wavelink.Player, interaction.guild.voice_client) + index = int(select.values[0]) + queue = player.queue.history if self.queue_page < 0 else player.queue + track = queue[index] + await queue.delete(index) + await interaction.response.defer() + await player.play(track) @discord.ui.button(label='◀', style=discord.ButtonStyle.grey, row=2, disabled=True) @user_bot_in_same_channel_check async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button): """ - fourth density + Loads previous page in the select window """ - voice_client: WavelinkPlayer = interaction.guild.voice_client self.queue_page -= 1 - self.update_select_state(voice_client) - coro = interaction.response.edit_message(view=self) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + self._update_track_selection_buttons() + await interaction.response.defer() + await self._update_embed() @discord.ui.button(label='▶', style=discord.ButtonStyle.grey, row=2, disabled=True) @user_bot_in_same_channel_check async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): """ - fourth density + Loads next page in the select window """ - voice_client: WavelinkPlayer = interaction.guild.voice_client self.queue_page += 1 - self.update_select_state(voice_client) - coro = interaction.response.edit_message(view=self) - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) + self._update_track_selection_buttons() + await interaction.response.defer() + await self._update_embed() - def update_view_state(self, voice_client: WavelinkPlayer): + async def __prepare_embed(self) -> discord.Embed: """ - calculates state of view + returns embed based on current state of voice_client """ - self.undo_button.disabled = voice_client.history.is_empty - self.pause_button.disabled = not voice_client.current - self.pause_button.label = '▶ Resume' if voice_client.is_paused() else '❚❚ Pause' - self.skip_button.disabled = not voice_client.current - self.stop_button.disabled = not voice_client.current - self.filter_button.disabled = False - self.filter_button.label = 'ඞ' if not voice_client.filter else '' - self.filter_button.emoji = ( - discord.PartialEmoji.from_str('') if voice_client.filter else None - ) - self.update_select_state(voice_client) + # Calculate queue time length + total_seconds = 0 + player = cast(wavelink.Player, self.text_channel.guild.voice_client) + for i, track in enumerate(player.queue): + total_seconds += track.length / 1000 + minutes = divmod(total_seconds, 60)[0] + hours, minutes = divmod(minutes, 60) + queue_time = f'⌛ {int(hours):02d} hr {int(minutes):02d} min' + + now_playing = player.current + if now_playing: + minutes, secs = divmod(now_playing.length / 1000, 60) + now_playing_time = f'⌛ {int(minutes):02d} min {int(secs):02d} s' + + # Prepare queue list + queue_preview = '' + if len(player.queue) > 10: + for i in range(10): + queue_preview += f"{i + 1}. {str(player.queue[i].title)}\n" + queue_preview += f'... and {len(player.queue) - 10} more.\n{queue_time}\n' + else: + for i, track in enumerate(player.queue): + queue_preview += f"{i + 1}. {str(track.title)}\n" + queue_preview += f'{queue_time}\n' + + # Create new embed + embed = discord.Embed(title='The Boi', color=0x00FF00, timestamp=datetime.datetime.now(datetime.timezone.utc)) + if len(player.queue) > 0: + embed.add_field(name='Queue:', value=queue_preview, inline=False) + if now_playing: + embed.add_field(name='Now Playing:', value=f'{now_playing.title}\n{now_playing_time}', inline=True) + thumbnail = now_playing.artwork + if thumbnail: + embed.set_thumbnail(url=thumbnail) + else: + embed.add_field(name='Nothing is playing right now', value=':(', inline=True) + embed.add_field(name='', value='▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁', inline=False) + embed.set_footer(text='2137', icon_url='https://media.tenor.com/mc3OyxhLazUAAAAM/doggo-doge.gif') + return embed + + async def _update_embed(self): + """ + Updates embed contents (does not remove / send) + """ + if not self.text_channel: + return + + embed = await self.__prepare_embed() + self._update_buttons_state() + if self.message_handle: + await self.message_handle.edit(view=self, embed=embed) - def update_select_state(self, voice_client: WavelinkPlayer): + def _update_track_selection_buttons(self): """ calculates state of select window and related buttons """ - queue_len = len(voice_client.queue) - history_len = len(voice_client.history) + player = cast(wavelink.Player, self.text_channel.guild.voice_client) + queue_len = len(player.queue) + history_len = len(player.queue.history) max_pages = max(queue_len - 1, 0) // MAX_SELECT_LEN min_pages = -(max(history_len - 1, 0) // MAX_SELECT_LEN) - 1 if history_len else 0 self.queue_page = min(max_pages, max(min_pages, self.queue_page)) # so page is correct on queue lenth change @@ -202,115 +268,46 @@ def update_select_state(self, voice_client: WavelinkPlayer): self.next_page.disabled = self.queue_page >= max_pages # fmt: off self.queue_select.disabled = ( - voice_client.queue.is_empty and self.queue_page >= 0) or ( - voice_client.history.is_empty and self.queue_page < 0 + not player.queue and self.queue_page >= 0) or ( + not player.queue.history and self.queue_page < 0 ) # fmt: on - if not voice_client.queue.is_empty and self.queue_page >= 0: # display current queue + if player.queue and self.queue_page >= 0: # display current queue self.queue_select.options = [ discord.SelectOption(label=f'{index +1}. {track.title}', value=str(index)) - for index, track in enumerate(voice_client.queue) + for index, track in enumerate(player.queue) if index + 1 >= first_index and index < last_index ] self.queue_select.placeholder = ( - f'Displaying: {first_index}-{min(last_index,len(voice_client.queue))} (current queue)' + f'Displaying: {first_index}-{min(last_index,len(player.queue))} (current queue)' ) elif self.queue_page < 0: ## display history queue self.queue_select.options = [ discord.SelectOption(label=f'{index +1}. {track.title}', value=str(history_len - 1 - index)) - for index, track in enumerate(list(voice_client.history)[::-1]) + for index, track in enumerate(list(player.queue.history)[::-1]) if index + 1 >= first_index and index < last_index ] self.queue_select.placeholder = ( - f'Displaying: {first_index}-{min(last_index,len(voice_client.history))} (history queue)' + f'Displaying: {first_index}-{min(last_index,len(player.queue.history))} (history queue)' ) else: self.queue_select.options = [discord.SelectOption(label=NOTHING_IN_QUEUE_PLACEHOLDER)] self.queue_select.placeholder = NOTHING_IN_QUEUE_PLACEHOLDER - async def wait_for_track_end(self) -> None: - """ - waits for the current track to end.\n - Useful because calling e.g. voice_client.skip() doesn't immidiately update voice_client correctly - so this helps ensure that update_buttons() will give correct result - """ - guild_id = self.text_channel.guild.id - audio_player_cog: AudioCog = self.bot.cogs["AudioCog"] - signal = audio_player_cog.track_state_change.get(guild_id) - try: - await asyncio.wait_for(signal.wait(), timeout=2) # just to make sure it doesnt wait forever - signal.clear() - except TimeoutError: - logging.warning('Timed out.') - - def remove_view(self): - """ - Removes embed with audio player information - """ - if self.message_handle: - coro = self.message_handle.delete() - self.stop() - self.clear_items() - asyncio.run_coroutine_threadsafe(coro, self.bot.loop) - - async def calculate_embed(self, voice_client: WavelinkPlayer) -> discord.Embed: - """ - returns embed based on current state of voice_client - """ - # Calculate queue time length - total_seconds = 0 - for i in range(voice_client.queue.count): - total_seconds += voice_client.queue[i].length / 1000 - minutes = divmod(total_seconds, 60)[0] - hours, minutes = divmod(minutes, 60) - queue_time = f'⌛ {int(hours):02d} hr {int(minutes):02d} min' - - now_playing = voice_client.current - if now_playing: - minutes, secs = divmod(now_playing.length / 1000, 60) - now_playing_time = f'⌛ {int(minutes):02d} min {int(secs):02d} s' - - # Prepare queue list - queue_preview = '' - if voice_client.queue.count > 10: - for i in range(10): - queue_preview += f"{i + 1}. {str(voice_client.queue[i].title)}\n" - queue_preview += f'... and {voice_client.queue.count - 10} more.\n{queue_time}\n' - else: - for i in range(voice_client.queue.count): - queue_preview += f"{i + 1}. {str(voice_client.queue[i].title)}\n" - queue_preview += f'{queue_time}\n' - - # Create new embed - embed = discord.Embed(title='The Boi', color=0x00FF00, timestamp=datetime.datetime.now(datetime.timezone.utc)) - if voice_client.queue.count > 0: - embed.add_field(name='Queue:', value=queue_preview, inline=False) - if now_playing: - embed.add_field(name='Now Playing:', value=f'{now_playing.title}\n{now_playing_time}', inline=True) - thumbnail = await wavelink.YouTubeTrack.fetch_thumbnail(now_playing) - if thumbnail: - embed.set_thumbnail(url=thumbnail) - else: - embed.add_field(name='Nothing is playing right now', value=':(', inline=True) - embed.add_field(name='', value='▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁', inline=False) - embed.set_footer(text='2137', icon_url='https://media.tenor.com/mc3OyxhLazUAAAAM/doggo-doge.gif') - return embed - - async def replace_message(self, voice_client: WavelinkPlayer): + def _update_buttons_state(self): """ - Removes last message and sends new one to keep it on the bottom of the chat\n + calculates state of view """ - embed = await self.calculate_embed(voice_client) - self.update_view_state(voice_client) - self._replace_message(embed, loop=self.bot.loop) + dancing_black_man = discord.PartialEmoji.from_str('') + amogus = 'ඞ' - @run_threadsafe - async def _replace_message(self, embed: discord.Embed, *, loop: asyncio.AbstractEventLoop): - """ - internal function for replacing handle - runs as threadsafe to prevent race condition - * run it without await - it is not a coroutine - """ - if self.message_handle: - await self.message_handle.delete() - self.message_handle = await self.text_channel.send(content=None, embed=embed, view=self) + player = cast(wavelink.Player, self.text_channel.guild.voice_client) + self.previous_button.disabled = not len(player.queue.history) > 0 + self.pause_button.label = '▶ Resume' if player.paused else '❚❚ Pause' + self.pause_button.disabled = not player.playing + self.skip_button.disabled = not player.playing + self.stop_button.disabled = not player.playing + self.filter_button.disabled = False + self.filter_button.label = amogus if not self.filters_applied else '' + self.filter_button.emoji = dancing_black_man if self.filters_applied else None + self._update_track_selection_buttons()