diff --git a/cogs/listeners.py b/cogs/listeners.py index a5c4a4d..90d0e90 100644 --- a/cogs/listeners.py +++ b/cogs/listeners.py @@ -21,9 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import voicelink +import os import asyncio import discord +import voicelink import function as func from discord.ext import commands @@ -36,10 +37,10 @@ class Listeners(commands.Cog): self.voicelink = voicelink.NodePool() bot.loop.create_task(self.start_nodes()) + bot.loop.create_task(self.restore_last_session_players()) async def start_nodes(self) -> None: """Connect and intiate nodes.""" - await self.bot.wait_until_ready() for n in func.settings.nodes.values(): try: await self.voicelink.create_node( @@ -52,6 +53,87 @@ class Listeners(commands.Cog): except Exception as e: func.logger.error(f'Node {n["identifier"]} is not able to connect! - Reason: {e}') + async def restore_last_session_players(self) -> None: + """Re-establish connections for players from the last session.""" + await self.bot.wait_until_ready() + players = func.open_json(func.LAST_SESSION_FILE_NAME) + if not players: + return + + for data in players: + try: + channel_id = data.get("channel_id") + if not channel_id: + continue + + channel = self.bot.get_channel(channel_id) + if not channel: + continue + elif not any(False if member.bot or member.voice.self_deaf else True for member in channel.members): + continue + + dj_member = channel.guild.get_member(data.get("dj")) + if not dj_member: + continue + + # Get the guild settings + settings = await func.get_settings(channel.guild.id) + + # Connect to the channel and initialize the player. + player: voicelink.Player = await channel.connect( + cls=voicelink.Player(self.bot, channel, func.TempCtx(dj_member, channel), settings) + ) + + # Restore the queue. + queue_data = data.get("queue", {}) + for track_data in queue_data.get("tracks", []): + track_id = track_data.get("track_id") + if not track_id: + continue + + decoded_track = voicelink.decode(track_id) + requester = channel.guild.get_member(track_data.get("requester_id")) + track = voicelink.Track(track_id=track_id, info=decoded_track, requester=requester) + player.queue._queue.append(track) + + # Restore queue settings. + player.queue._position = queue_data.get("position", 0) - 1 + repeat_mode = queue_data.get("repeat_mode", "OFF") + try: + loop_mode = voicelink.LoopType[repeat_mode] + except KeyError: + loop_mode = voicelink.LoopType.OFF + player.queue._repeat.set_mode(loop_mode) + player.queue._repeat_position = queue_data.get("repeat_position") + + # Restore player settings + player.dj = dj_member + player.settings['autoplay'] = data.get('autoplay', False) + + # Resume playback or invoke the controller based on the player's state. + if not player.is_playing: + await player.do_next() + + if is_paused := data.get("is_paused"): + await player.set_pause(is_paused, self.bot.user) + + if position := data.get("position"): + await player.seek(int(position), self.bot.user) + + await asyncio.sleep(5) + + except Exception as e: + func.logger.error(f"Error encountered while restoring a player for channel ID {channel_id}.", exc_info=e) + + # Delete the last session file if it exists. + try: + file_path = os.path.join(func.ROOT_DIR, func.LAST_SESSION_FILE_NAME) + if os.path.exists(file_path): + os.remove(file_path) + + except Exception as del_error: + func.logger.error("Failed to remove session file: %s", file_path, exc_info=del_error) + @commands.Cog.listener() async def on_voicelink_track_end(self, player: voicelink.Player, track, _): await player.do_next() diff --git a/function.py b/function.py index 2b9d9cd..e8fcae5 100644 --- a/function.py +++ b/function.py @@ -73,6 +73,14 @@ USER_BASE: dict[str, Any] = { } ALLOWED_MENTIONS = discord.AllowedMentions().none() +LAST_SESSION_FILE_NAME = "last-session.json" + +#-------------- Vocard Classes -------------- +class TempCtx(): + def __init__(self, author: discord.Member, channel: discord.VoiceChannel) -> None: + self.author: discord.Member = author + self.channel: discord.VoiceChannel = channel + self.guild: discord.Guild = channel.guild #-------------- Vocard Functions -------------- def open_json(path: str) -> dict: @@ -85,9 +93,9 @@ def open_json(path: str) -> dict: def update_json(path: str, new_data: dict) -> None: data = open_json(path) if not data: - return - - data.update(new_data) + data = new_data + else: + data.update(new_data) with open(os.path.join(ROOT_DIR, path), "w") as json_file: json.dump(data, json_file, indent=4) diff --git a/ipc/methods.py b/ipc/methods.py index c782184..6d05a4b 100644 --- a/ipc/methods.py +++ b/ipc/methods.py @@ -22,12 +22,6 @@ SCOPES = { "stage_announce_template": str } -class TempCtx(): - def __init__(self, author: Member, channel: VoiceChannel) -> None: - self.author = author - self.channel = channel - self.guild = channel.guild - class SystemMethod: def __init__(self, function: callable, *, credit: int = 1): self.function: callable = function @@ -44,9 +38,9 @@ def require_permission(only_admin: bool = False): def decorator(func) -> callable: async def wrapper(player: Player, member: Member, dict: Dict) -> Optional[Dict]: if only_admin and not member.guild_permissions.manage_guild: - return error_msg("Only the admins may use this funciton!", user_id=member.id) + return error_msg("Only the admins may use this function!", user_id=member.id) if not player.is_privileged(member): - return error_msg("Only the DJ or admins may use this funciton!", user_id=member.id) + return error_msg("Only the DJ or admins may use this function!", user_id=member.id) return await func(player, member, dict) return wrapper return decorator @@ -67,7 +61,7 @@ async def connect_channel(member: Member, bot: commands.Bot) -> Player: channel = member.voice.channel try: settings = await func.get_settings(channel.guild.id) - player: Player = await channel.connect(cls=Player(bot, channel, TempCtx(member, channel), settings)) + player: Player = await channel.connect(cls=Player(bot, channel, func.TempCtx(member, channel), settings)) await player.send_ws({"op": "createPlayer", "memberIds": [str(member.id) for member in channel.members]}) return player except: @@ -438,13 +432,13 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "userId": str(user_id) } - assgined_playlist_id = _assign_playlist_id(list(playlist.keys())) + assigned_playlist_id = _assign_playlist_id(list(playlist.keys())) data = {'uri': playlist_url, 'perms': {'read': []}, 'name': name, 'type': 'link'} if playlist_url else {'tracks': [], 'perms': {'read': [], 'write': [], 'remove': []}, 'name': name, 'type': 'playlist'} - await func.update_user(user_id, {"$set": {f"playlist.{assgined_playlist_id}": data}}) + await func.update_user(user_id, {"$set": {f"playlist.{assigned_playlist_id}": data}}) return { "op": "updatePlaylist", "status": "created", - "playlistId": assgined_playlist_id, + "playlistId": assigned_playlist_id, "msg": f"You have created '{name}' playlist.", "userId": str(user_id), "data": data @@ -579,7 +573,7 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: if refer_id not in share_playlists: return error_msg("The shared playlist couldn’t be found. It’s possible that the user has already deleted it.", user_id=user_id) - assgined_playlist_id = _assign_playlist_id(list(user.get("playlist", []).keys())) + assigned_playlist_id = _assign_playlist_id(list(user.get("playlist", []).keys())) playlist_name = f"Share{time.strftime('%M%S', time.gmtime(int(mail['time'])))}" share_playlist = share_playlists.get(refer_id) share_playlist.update({ @@ -588,7 +582,7 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: }) await func.update_user(mail['sender'], {"$push": {f"playlist.{mail['referId']}.perms.read": user_id}}) await func.update_user(user_id, {"$set": { - f'playlist.{assgined_playlist_id}': { + f'playlist.{assigned_playlist_id}': { 'user': mail['sender'], 'referId': mail['referId'], 'name': playlist_name, 'type': 'share' @@ -597,7 +591,7 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: }}) payload.update({ - "playlistId": assgined_playlist_id, + "playlistId": assigned_playlist_id, "msg": f"You have created '{playlist_name}' playlist.", "data": share_playlist, }) diff --git a/main.py b/main.py index f8e6d08..202db23 100644 --- a/main.py +++ b/main.py @@ -27,13 +27,13 @@ import os import aiohttp import update import logging +import voicelink import function as func from discord.ext import commands from ipc import IPCClient from motor.motor_asyncio import AsyncIOMotorClient from logging.handlers import TimedRotatingFileHandler -from voicelink import VoicelinkException from addons import Settings class Translator(discord.app_commands.Translator): @@ -181,7 +181,7 @@ class Vocard(commands.Bot): embed.set_footer(icon_url=ctx.me.display_avatar.url, text=f"More Help: {func.settings.invite_link}") return await ctx.reply(embed=embed) - elif not issubclass(error.__class__, VoicelinkException): + elif not issubclass(error.__class__, voicelink.VoicelinkException): error = await func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link func.logger.error(f"An unexpected error occurred in the {ctx.command.name} command on the {ctx.guild.name}({ctx.guild.id}).", exc_info=exception) diff --git a/views/debug.py b/views/debug.py index 5e8125e..8ec1e80 100644 --- a/views/debug.py +++ b/views/debug.py @@ -23,6 +23,7 @@ SOFTWARE. import discord import io +import os import contextlib import textwrap import traceback @@ -132,7 +133,7 @@ class CogsDropdown(discord.ui.Select): selected = self.values[0].lower() try: if selected == "all": - for name in self.bot.cogs.keys(): + for name in self.bot.cogs.copy().keys(): await self.bot.reload_extension(f"cogs.{name.lower()}") else: await self.bot.reload_extension(f"cogs.{selected}") @@ -384,4 +385,31 @@ class DebugView(discord.ui.View): async def nodes(self, interaction: discord.Interaction, button: discord.ui.Button): view = NodesPanel(self.bot) await interaction.response.send_message(embed=view.build_embed(), view=view, ephemeral=True) - view.message = await interaction.original_response() \ No newline at end of file + view.message = await interaction.original_response() + + @discord.ui.button(label="Stop-Bot", emoji="🔴") + async def stop(self, interaction: discord.Interaction, button: discord.ui.Button): + for name in self.bot.cogs.copy().keys(): + try: + await self.bot.unload_extension(name) + except: + pass + + player_data = [] + for identifier, node in voicelink.NodePool._nodes.items(): + for guild_id, player in node._players.copy().items(): + if not player.guild.me.voice or not player.current: + continue + + player_data.append(player.data) + try: + await player.teardown() + except: + pass + + session_file_path = os.path.join(func.ROOT_DIR, func.LAST_SESSION_FILE_NAME) + if os.path.exists(session_file_path): + os.remove(session_file_path) + + func.update_json(func.LAST_SESSION_FILE_NAME, player_data) + await interaction.client.close() \ No newline at end of file diff --git a/voicelink/objects.py b/voicelink/objects.py index d95b2c4..7c8bdcf 100644 --- a/voicelink/objects.py +++ b/voicelink/objects.py @@ -132,8 +132,7 @@ class Track: def data(self) -> dict: return { "track_id": self.track_id, - "info": self.info, - "thumbnail": self.thumbnail + "requester_id": self.requester.id } class Playlist: diff --git a/voicelink/player.py b/voicelink/player.py index b17ff76..4480b79 100644 --- a/voicelink/player.py +++ b/voicelink/player.py @@ -224,6 +224,27 @@ class Player(VoiceProtocol): """Calculates and returns the player's current ping in seconds.""" return round(self._ping / 1000, 2) + @property + def autoplay(self) -> bool: + return self.settings.get("autoplay", False) + + @property + def data(self) -> dict: + return { + "guild_id": self._guild.id, + "channel_id": self.channel.id, + "queue": { + "tracks": [track.data for track in self.queue._queue], + "position": self.queue._position, + "repeat_mode": self.queue._repeat.current.name, + "repeat_position": self.queue._repeat_position + }, + "dj": self.dj.id, + "is_paused": self.is_paused, + "position": self.position, + "autoplay": self.autoplay + } + @property def is_ipc_connected(self) -> bool: """Indicates whether the Inter-Process Communication (IPC) connection is active.""" @@ -388,7 +409,7 @@ class Player(VoiceProtocol): track = self.queue.get() if not track: - if self.settings.get("autoplay", False) and await self.get_recommendations(): + if self.autoplay and await self.get_recommendations(): return await self.do_next() else: try: @@ -403,9 +424,7 @@ class Player(VoiceProtocol): "$push": {"history": {"$each": [track.track_id], "$slice": -25}} })) - if self.settings.get('controller', True): - await self.invoke_controller() - + await self.invoke_controller() await self.update_voice_status() if self.is_ipc_connected: @@ -418,6 +437,9 @@ class Player(VoiceProtocol): async def invoke_controller(self): """Sends or updates the music controller message in the designated channel.""" + if not self.settings.get('controller', True): + return + if self._updating or not self.channel: return