diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 3a99ae8..c96d10f 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -2,11 +2,9 @@ name: Docker Image CI on: push: - branches: [ "**" ] + branches: [ "main" ] paths-ignore: - '**.md' - pull_request: - branches: [ "**" ] jobs: build: diff --git a/cogs/settings.py b/cogs/settings.py index 2e99ec4..6a88aef 100644 --- a/cogs/settings.py +++ b/cogs/settings.py @@ -270,9 +270,14 @@ class Settings(commands.Cog, name="settings"): channel_perms = channel.permissions_for(ctx.me) if not channel_perms.text() and not channel_perms.manage_messages: return await send(ctx, "noCreatePermission") + + settings = await func.get_settings(ctx.guild.id) + controller = settings.get("default_controller", func.settings.controller).get("embeds", {}).get("inactive", {}) + message = await channel.send(embed=voicelink.build_embed(controller, voicelink.Placeholders(self.bot))) await update_settings(ctx.guild.id, {"$set": {'music_request_channel': { - "text_channel_id": channel.id + "text_channel_id": channel.id, + "controller_msg_id": message.id, }}}) await send(ctx, "createSongRequestChannel", channel.mention) diff --git a/voicelink/player.py b/voicelink/player.py index 1671e15..9756d5e 100644 --- a/voicelink/player.py +++ b/voicelink/player.py @@ -36,8 +36,8 @@ from discord import ( VoiceProtocol, Member, Message, + PartialMessage, Interaction, - errors ) from discord.ext import commands @@ -112,7 +112,7 @@ class Player(VoiceProtocol): self.queue: Queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.settings.max_queue), self.settings.get("duplicateTrack", True), self.get_msg) self._node = NodePool.get_node() - self._current: Track = None + self._current: Optional[Track] = None self._filters: Filters = Filters() self._paused: bool = False self._is_connected: bool = False @@ -126,8 +126,8 @@ class Player(VoiceProtocol): self._voice_state: dict = {} - self.controller: Message = None - self.updating: bool = False + self.controller: Union[Message, PartialMessage] = None + self._updating: bool = False self.pause_votes = set() self.resume_votes = set() @@ -180,7 +180,7 @@ class Player(VoiceProtocol): return self._is_connected and self._paused @property - def current(self) -> Track: + def current(self) -> Optional[Track]: """Property which returns the currently playing track""" return self._current @@ -218,12 +218,26 @@ class Player(VoiceProtocol): @property def ping(self) -> float: + """Calculates and returns the player's current ping in seconds.""" return round(self._ping / 1000, 2) - + + @property + def is_ipc_connected(self) -> bool: + """Indicates whether the Inter-Process Communication (IPC) connection is active.""" + return self._ipc._is_connected and self._ipc_connection + def get_msg(self, *keys) -> Union[list[str], str]: + """Retrieves a localized message or list of messages based on the given keys + for the guild associated with this player. + """ return func.get_lang_non_async(self.guild.id, *keys) def required(self, leave=False): + """ + Calculates the number of votes required for a specific action in the voice channel. + + If `leave` is True and the channel has three members, the requirement adjusts to 2 votes. + """ if self.settings.get('votedisable'): return 0 @@ -233,18 +247,22 @@ class Player(VoiceProtocol): required = 2 return required - - @property - def is_ipc_connected(self) -> bool: - return self._ipc._is_connected and self._ipc_connection def is_user_join(self, user: Member): + """Checks if a user is present in the voice channel or has 'Manage Server' permission.""" if user not in self.channel.members: if not user.guild_permissions.manage_guild: return False return True def is_privileged(self, user: Member, check_user_join: bool = True) -> bool: + """ + Determines if a user has privileged access. + + Privileged access is granted if the user is in the bot access list, + has 'Manage Server' permission, or meets the DJ role criteria in the settings. + Raises an exception if `check_user_join` is True and the user is not in the channel. + """ if user.id in func.settings.bot_access_user: return True @@ -256,11 +274,20 @@ class Player(VoiceProtocol): return manage_perm or (self.settings['dj'] in [role.id for role in user.roles]) return self.dj.id == user.id or manage_perm + def build_embed(self, current_track: Track = None): + """Builds an embed based on the current track state.""" + controller = self.settings.get("default_controller", func.settings.controller).get("embeds", {}) + raw = controller.get("active" if current_track else "inactive", {}) + + return build_embed(raw, self._ph) + async def send(self, method: RequestMethod, query: str = None, data: Union[Dict, str] = {}) -> Dict: + """Sends an HTTP request to the node with the given method, query, and data.""" uri: str = f"sessions/{self._node._session_id}/players/{self._guild.id}" + (f"?{query}" if query else "") return await self._node.send(method, query=uri, data=data) async def _update_state(self, data: dict) -> None: + """Updates the player's state based on the provided data.""" state: dict = data.get("state") self._last_update = time.time() * 1000 self._is_connected = state.get("connected") @@ -277,6 +304,7 @@ class Player(VoiceProtocol): }) async def _dispatch_voice_update(self, voice_data: Dict[str, Any] = None): + """Dispatches a voice update to the node.""" if {"sessionId", "event"} != self._voice_state.keys(): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched voice update failed {voice_data}") return @@ -293,10 +321,12 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched voice update to {state['event']['endpoint']} with data {data}") async def on_voice_server_update(self, data: dict): + """Handles a voice server update event.""" self._voice_state.update({"event": data}) await self._dispatch_voice_update(self._voice_state) async def on_voice_state_update(self, data: dict): + """Handles a voice state update event.""" self._voice_state.update({"sessionId": data.get("session_id")}) if not (channel_id := data.get("channel_id")): @@ -312,6 +342,7 @@ class Player(VoiceProtocol): await self._dispatch_voice_update({**self._voice_state, "event": data}) async def _dispatch_event(self, data: dict): + """Dispatches an event based on the type of event data received.""" event_type = data.get("type") event: VoicelinkEvent = getattr(events, event_type)(data, self) @@ -326,6 +357,7 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched event {event_type}.") async def do_next(self): + """Processes the next track in the queue.""" if self._current or self.is_playing or not self.channel: return @@ -378,42 +410,45 @@ class Player(VoiceProtocol): }) async def invoke_controller(self): - if self.updating or not self.channel: + """Sends or updates the music controller message in the designated channel.""" + if self._updating or not self.channel: return - self.updating = True + self._updating = True - try: - embed, view = await self.build_embed(), InteractiveController(self) + try: + embed, view = self.build_embed(self.current), InteractiveController(self) if not self.controller: - self.controller = await self.context.channel.send(embed=embed, view=view) + if request_channel_data := self.settings.get("music_request_channel"): + channel = self.bot.get_channel(request_channel_data.get("text_channel_id")) + if channel: + self.controller = channel.get_partial_message(request_channel_data.get("controller_msg_id")) + await self.controller.edit(embed=embed, view=view) + + # Send a new controller message if none exists + if not self.controller: + self.controller = await self.context.channel.send(embed=embed, view=view) elif not await self.is_position_fresh(): try: await self.controller.delete() - except: - pass + except Exception as e: + self._logger.warning( + f"Failed to delete outdated controller in {self.guild.name}({self.guild.id}): {e}" + ) self.controller = await self.context.channel.send(embed=embed, view=view) else: await self.controller.edit(embed=embed, view=view) - except errors.Forbidden: - pass - except Exception as e: self._logger.error(f"Something went wrong while sending music controller to {self.guild.name}({self.guild.id})", exc_info=e) - pass - - self.updating = False - - async def build_embed(self): - controller = self.settings.get("default_controller", func.settings.controller).get("embeds", {}) - raw = controller.get("active" if self.current else "inactive", {}) - return build_embed(raw, self._ph) + finally: + self._updating = False async def is_position_fresh(self): + """Checks if the current controller message is among the most recent messages.""" try: async for message in self.context.channel.history(limit=5): if message.id == self.controller.id: @@ -424,19 +459,24 @@ class Player(VoiceProtocol): return False async def teardown(self): - await func.update_settings( - self.guild.id, - {"$set": { + """Cleans up the player and associated resources.""" + try: + await func.update_settings(self.guild.id, {"$set": { "lastActice": (timeNow := round(time.time())), "playTime": round(self.settings.get("playTime", 0) + ((timeNow - self.joinTime) / 60), 2) - }} - ) - await self.update_voice_status(remove_status=True) - if self.is_ipc_connected: - await self.send_ws({"op": "playerClose"}) + }}) + + if self.is_ipc_connected: + await self.send_ws({"op": "playerClose"}) + except: + pass try: - await self.controller.delete() + await self.update_voice_status(remove_status=True) + if self.controller and self.controller.id == self.settings.get("music_request_channel", {}).get("controller_msg_id"): + await self.controller.edit(embed=self.build_embed(), view=None) + else: + await self.controller.delete() except: pass @@ -464,6 +504,7 @@ class Player(VoiceProtocol): return await self._node.get_tracks(query, requester=requester, search_type=search_type) async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = True, self_mute: bool = False): + """Connects the player to a voice channel.""" await self.guild.change_voice_state(channel=self.channel, self_deaf=True, self_mute=self_mute) self._node._players[self.guild.id] = self self._is_connected = True @@ -538,6 +579,7 @@ class Player(VoiceProtocol): return self._current def _validate_time(self, track: Track, start_time: int, end_time: int) -> None: + """Validates the start and end times for a track.""" if start_time or end_time: if not end_time: end_time = track.length @@ -555,6 +597,7 @@ class Player(VoiceProtocol): track.end_time = end_time async def add_track(self, raw_tracks: Union[Track, List[Track]], *, start_time: int = 0, end_time: int = 0, at_front: bool = False, duplicate: bool = True) -> int: + """Adds one or more tracks to the queue.""" tracks: List[Track] = [] _duplicate_tracks = [] if self.queue._allow_duplicate and duplicate else [track.uri for track in self.queue._queue] raw_tracks = raw_tracks[0] if isinstance(raw_tracks, List) and len(raw_tracks) == 1 else raw_tracks @@ -586,6 +629,7 @@ class Player(VoiceProtocol): return len(tracks) if is_list else position async def remove_track(self, index: int, index2: int = None, remove_target: Member = None, requester: Member = None) -> Dict[int, Track]: + """Removes one or more tracks from the queue.""" removed_tracks = self.queue.remove(index, index2, remove_target) if removed_tracks and self.is_ipc_connected: await self.send_ws({ @@ -651,6 +695,7 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been shuffled the queue.") async def swap_track(self, index1: int, index2: int, requester: Member = None) -> Tuple[Track, Track]: + """Swaps two tracks in the queue at the specified indices.""" track1, track2 = self.queue.swap(index1, index2) if self.is_ipc_connected: await self.send_ws({ @@ -661,6 +706,7 @@ class Player(VoiceProtocol): return track1, track2 async def move_track(self, index: int, new_index: int, requester: Member = None) -> Optional[Track]: + """Moves a track from its current position to a new position in the queue.""" moved_track = self.queue.move(index, new_index) if self.is_ipc_connected: @@ -669,6 +715,7 @@ class Player(VoiceProtocol): return moved_track async def set_repeat(self, mode: LoopType = None, requester: Member = None) -> LoopType: + """Sets the repeat mode for the queue.""" if not mode: mode = self.queue._repeat.next() @@ -684,6 +731,7 @@ class Player(VoiceProtocol): return mode async def add_filter(self, filter: Filter, requester: Member = None, fast_apply: bool = False) -> Filters: + """Adds a filter to the player's audio stream.""" try: self._filters.add_filter(filter=filter) except FilterTagAlreadyInUse: @@ -705,6 +753,7 @@ class Player(VoiceProtocol): return self._filters async def clear_queue(self, queue_type: str, requester: Member = None) -> None: + """Clears the queue or the history of tracks.""" queue_type = queue_type.lower() if queue_type == 'history': self.queue.history_clear(self.is_playing) @@ -735,6 +784,7 @@ class Player(VoiceProtocol): return self._filters async def reset_filter(self, *, requester: Member = None, fast_apply=False) -> None: + """Resets all filters applied to the player's audio stream.""" if not self._filters: raise FilterInvalidArgument("You must have filters applied first in order to use this method.") @@ -752,7 +802,7 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been removed all filters.") async def change_node(self, identifier: str = None) -> None: - """Change node.""" + """Changes the audio processing node for the guild..""" try: node = NodePool.get_node(identifier=identifier) except: @@ -791,6 +841,7 @@ class Player(VoiceProtocol): return False async def update_voice_status(self, remove_status: bool = False) -> None: + """Updates the voice status of the channel based on the specified template.""" template = self.settings.get("stage_announce_template", func.settings.voice_status_template) if not template or not self.channel: return @@ -810,6 +861,7 @@ class Player(VoiceProtocol): ) async def send_ws(self, payload, requester: Member = None): + """Sends a WebSocket payload to the bot's IPC (Inter-Process Communication) system.""" payload['guild_id'] = str(self.guild.id) if requester: payload['requester_id'] = str(requester.id)