diff --git a/pomice/events.py b/pomice/events.py index 49af9bd..7d6f758 100644 --- a/pomice/events.py +++ b/pomice/events.py @@ -25,14 +25,7 @@ __all__ = ( class PomiceEvent(ABC): - """The base class for all events dispatched by a node. - Every event must be formatted within your bot's code as a listener. - i.e: If you want to listen for when a track starts, the event would be: - ```py - @bot.listen - async def on_pomice_track_start(self, event): - ``` - """ + """The base class for all events dispatched by a node.""" name = "event" handler_args: Tuple diff --git a/pomice/exceptions.py b/pomice/exceptions.py index 4019e3b..c3d3dd4 100644 --- a/pomice/exceptions.py +++ b/pomice/exceptions.py @@ -7,6 +7,7 @@ __all__ = ( "NodeRestException", "NodeNotAvailable", "NoNodesAvailable", + "PlayerCreationError", "TrackInvalidPosition", "TrackLoadError", "FilterInvalidArgument", @@ -61,6 +62,12 @@ class NoNodesAvailable(PomiceException): pass +class PlayerCreationError(PomiceException): + """There was a problem while creating the player.""" + + pass + + class TrackInvalidPosition(PomiceException): """An invalid position was chosen for a track.""" diff --git a/pomice/player.py b/pomice/player.py index 3dbc15f..f58d9ac 100644 --- a/pomice/player.py +++ b/pomice/player.py @@ -121,19 +121,19 @@ class Filters: class Player: """The base player class for Pomice. - In order to initiate a player, you must pass it in as a cls when you connect to a channel. - i.e: ```py - await ctx.author.voice.channel.connect(cls=pomice.Player) + In order to initialize a player, you must pass in a guild id and register it to a node. + You can also pass in a node if you would like to specify which node to use. + + Example: + ```py + player = pomice.Player(guild_id=1234567890) + node.register_player(player) ``` """ __slots__ = ( - "client", - "channel", - "_bot", - "_guild", "_node", - "_current", + "_guild_id" "_current", "_filters", "_volume", "_paused", @@ -149,9 +149,11 @@ class Player: def __init__( self, *, + guild_id: int, node: Optional[Node] = None, ) -> None: self._node: Node = node if node else NodePool.get_node() + self._guild_id: int = guild_id self._current: Optional[Track] = None self._filters: Filters = Filters() self._volume: int = 100 @@ -163,13 +165,11 @@ class Player: self._ending_track: Optional[Track] = None self._log = self._node._log - self._voice_state: dict = {} - self._player_endpoint_uri: str = f"sessions/{self._node._session_id}/players" def __repr__(self) -> str: return ( - f"" ) @@ -236,6 +236,11 @@ class Player: """Property which returns the node the player is connected to""" return self._node + @property + def guild_id(self) -> int: + """Property which returns the guild id associated with this player instance""" + return self._guild_id + @property def volume(self) -> int: """Property which returns the players current volume""" @@ -271,26 +276,37 @@ class Player: self._last_position = int(state.get("position", 0)) self._log.debug(f"Got player update state with data {state}") - async def _dispatch_voice_update(self, voice_data: Optional[Dict[str, Any]] = None) -> None: - if {"sessionId", "event"} != self._voice_state.keys(): - return + async def dispatch_voice_update(self, voice_data: dict) -> None: + """ + Dispatches a voice update to the node. + This method is required for the player to work properly. - state = voice_data or self._voice_state - - data = { - "token": state["event"]["token"], - "endpoint": state["event"]["endpoint"], - "sessionId": state["sessionId"], + All data must be formatted as follows: + ```json + { + "token": "voice token", + "endpoint": "voice endpoint url", + "sessionId": "voice session id" } + ``` + + """ + if not voice_data: + raise ValueError("Voice data must be passed in.") + + if not all(key in voice_data for key in ("token", "endpoint", "sessionId")): + raise ValueError("Voice data must contain 'token', 'endpoint' and 'sessionId' keys.") await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, - data={"voice": data}, + guild_id=self._guild_id, + data={"voice": voice_data}, ) - self._log.debug(f"Dispatched voice update to {state['event']['endpoint']} with data {data}") + self._log.debug( + f"Dispatched voice update to {voice_data['endpoint']} with data {voice_data}", + ) async def _dispatch_event(self, data: dict) -> None: event_type: str = data["type"] @@ -313,16 +329,16 @@ class Player: if self.current: data: dict = {"position": self.position, "encodedTrack": self.current.track_id} - del self._node._players[self._guild.id] + del self._node._players[self._guild_id] self._node = new_node - self._node._players[self._guild.id] = self + self._node._players[self._guild_id] = self # reassign uri to update session id await self._refresh_endpoint_uri(new_node._session_id) await self._dispatch_voice_update() await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data=data or None, ) @@ -341,9 +357,6 @@ class Player: you can also pass in a Spotify URL of a playlist, album or track and it will be parsed accordingly. - You can pass in a discord.py Context object to get a - Context object on any track you search. - You may also pass in a List of filters to be applied to your track once it plays. """ @@ -359,8 +372,6 @@ class Player: async def get_recommendations(self, *, track: Track) -> Optional[Union[List[Track], Playlist]]: """ Gets recommendations from either YouTube or Spotify. - You can pass in a discord.py Context object to get a - Context object on all tracks that get recommended. """ return await self._node.get_recommendations(track=track) @@ -370,27 +381,20 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"encodedTrack": None}, ) self._log.debug(f"Player has been stopped.") async def destroy(self) -> None: - """Disconnects and destroys the player, and runs internal cleanup.""" - try: - await self.disconnect() - except AttributeError: - # 'NoneType' has no attribute '_get_voice_client_key' raised by self.cleanup() -> - # assume we're already disconnected and cleaned up - assert self.channel is None and not self.is_connected - - self._node._players.pop(self.guild.id) + """Destroys the player and removes it from the node's stored players.""" + self._node._players.pop(self.guild_id) if self.node.is_connected: await self._node.send( method="DELETE", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, ) self._log.debug("Player has been destroyed.") @@ -473,7 +477,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data=data, query=f"noReplace={ignore_if_playing}", ) @@ -497,7 +501,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"position": position}, ) @@ -509,7 +513,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"paused": pause}, ) self._paused = pause @@ -522,7 +526,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"volume": volume}, ) self._volume = volume @@ -543,7 +547,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"filters": payload}, ) @@ -567,7 +571,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"filters": payload}, ) self._log.debug(f"Filter has been removed from player with tag {filter_tag}") @@ -594,7 +598,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"filters": payload}, ) self._log.debug(f"Filter with tag {filter_tag} has been edited to {edited_filter!r}") @@ -620,7 +624,7 @@ class Player: await self._node.send( method="PATCH", path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=self._guild_id, data={"filters": {}}, ) self._log.debug(f"All filters have been removed from player.") diff --git a/pomice/pool.py b/pomice/pool.py index 3f63a3f..cba7ff2 100644 --- a/pomice/pool.py +++ b/pomice/pool.py @@ -35,6 +35,7 @@ from .exceptions import NodeCreationError from .exceptions import NodeNotAvailable from .exceptions import NodeRestException from .exceptions import NoNodesAvailable +from .exceptions import PlayerCreationError from .exceptions import TrackLoadError from .filters import Filter from .objects import Playlist @@ -64,8 +65,7 @@ class Node: """ __slots__ = ( - "_bot", - "_bot_user", + "_bot_id", "_host", "_port", "_pool", @@ -153,14 +153,9 @@ class Node: self._route_planner = RoutePlanner(self) self._log = self._setup_logging(self._log_level) - if not self._bot.user: - raise NodeCreationError("Bot user is not ready yet.") - - self._bot_user = self._bot.user - self._headers = { "Authorization": self._password, - "User-Id": str(self._bot_user.id), + "User-Id": str(self._bot_id), "Client-Name": f"Pomice/{__version__}", } @@ -181,8 +176,6 @@ class Node: if apple_music: self._apple_music_client = applemusic.Client() - self._bot.add_listener(self._update_handler, "on_socket_response") - def __repr__(self) -> str: return ( f" None: - await self._bot.wait_until_ready() - - if not data: - return - - if data["t"] == "VOICE_SERVER_UPDATE": - guild_id = int(data["d"]["guild_id"]) - try: - player = self._players[guild_id] - await player.on_voice_server_update(data["d"]) - except KeyError: - return - - elif data["t"] == "VOICE_STATE_UPDATE": - if int(data["d"]["user_id"]) != self._bot_user.id: - return - - guild_id = int(data["d"]["guild_id"]) - try: - player = self._players[guild_id] - await player.on_voice_state_update(data["d"]) - except KeyError: - return - async def _handle_node_switch(self) -> None: nodes = [node for node in self.pool._nodes.copy().values() if node.is_connected] new_node = random.choice(nodes) @@ -444,9 +412,25 @@ class Node: """Takes a guild ID as a parameter. Returns a pomice Player object or None.""" return self._players.get(guild_id, None) + def register_player(self, player: Player) -> None: + """Registers a player to the node.""" + if not player.guild_id: + raise PlayerCreationError( + "You must pass in a guild ID to create a player.", + ) + + if player.guild_id in self._players: + raise PlayerCreationError( + f"Player with guild ID {player.guild_id} already exists.", + ) + + self._players[player.guild_id] = player + self._log.debug( + f"Registered player with guild ID {player.guild_id} to Node {self._identifier}", + ) + async def connect(self, *, reconnect: bool = False) -> "Node": """Initiates a connection with a Lavalink node and adds it to the node pool.""" - await self._bot.wait_until_ready() start = time.perf_counter() @@ -563,9 +547,6 @@ class Node: If you passed in Spotify API credentials, you can also pass in a Spotify URL of a playlist, album or track and it will be parsed accordingly. - You can pass in a discord.py Context object to get a - Context object on any track you search. - You may also pass in a List of filters to be applied to your track once it plays. """ @@ -827,8 +808,6 @@ class Node: Gets recommendations from either YouTube or Spotify. The track that is passed in must be either from YouTube or Spotify or else this will not work. - You can pass in a discord.py Context object to get a - Context object on all tracks that get recommended. """ if track.track_type == TrackType.SPOTIFY: results = await self._spotify_client.get_recommendations(query=track.uri) # type: ignore