Added Spotify functionality and added docs to every function and property that needed it

This commit is contained in:
cloudwithax 2021-10-03 10:53:49 -04:00
parent eb7c529c14
commit f83e9e9cca
9 changed files with 89 additions and 10 deletions

View File

@ -1,6 +1,6 @@
"""Pomice wrapper for Lavalink, made possible by cloudwithax 2021"""
__version__ = "1.0.1"
__version__ = "1.0.3"
__title__ = "pomice"
__author__ = "cloudwithax"

View File

@ -2,12 +2,21 @@ from .pool import NodePool
class PomiceEvent:
"""The base class for all events dispatched by a node.
Every event must be formatted within your bots 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):
```
"""
def __init__(self):
pass
name = 'event'
class TrackStartEvent(PomiceEvent):
"""Fired when a track has successfully started. Returns the player associated with said track and the track ID"""
def __init__(self, data):
super().__init__()
@ -21,6 +30,7 @@ class TrackStartEvent(PomiceEvent):
class TrackEndEvent(PomiceEvent):
"""Fired when a track has successfully ended. Returns the player associated with the track along with the track ID and reason."""
def __init__(self, data):
super().__init__()
@ -33,6 +43,7 @@ class TrackEndEvent(PomiceEvent):
return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>"
class TrackStuckEvent(PomiceEvent):
"""Fired when a track is stuck and cannot be played. Returns the player associated with the track along with a track ID to be further parsed by the end user."""
def __init__(self, data):
super().__init__()
@ -46,6 +57,7 @@ class TrackStuckEvent(PomiceEvent):
return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>"
class TrackExceptionEvent(PomiceEvent):
"""Fired when a track error has occured. Returns the player associated with the track along with the error code and execption"""
def __init__(self, data):
super().__init__()
@ -59,11 +71,11 @@ class TrackExceptionEvent(PomiceEvent):
return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}"
class WebsocketClosedEvent(PomiceEvent):
"""Fired when a websocket connection to a node has been closed. Returns the reason and the error code."""
def __init__(self, data):
super().__init__()
self.name = "websocket_closed"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.reason = data["reason"]
self.code = data["code"]
@ -72,11 +84,11 @@ class WebsocketClosedEvent(PomiceEvent):
return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>"
class WebsocketOpenEvent(PomiceEvent):
"""Fired when a websocket connection to a node has been initiated. Returns the target and the session SSRC."""
def __init__(self, data):
super().__init__()
self.name = "websocket_open"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.target: str = data['target']
self.ssrc: int = data['ssrc']

View File

@ -54,3 +54,7 @@ class SpotifyTrackLoadFailed(PomiceException):
class SpotifyPlaylistLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load a playlist"""
pass
class InvalidSpotifyClientAuthorization(PomiceException):
"""No Spotify client authorization was provided in order to use the Spotify track search feature"""
pass

View File

@ -8,6 +8,10 @@ class Filter:
class Timescale(Filter):
"""Filter which changes the speed and pitch of a track. Do be warned that this filter is bugged as of the lastest
Lavalink dev version due to the filter patch not corresponding with the track time. In short this means that your
track will either end prematurely or end later due to this. This is not the library's fault.
"""
def __init__(self, *, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
super().__init__()
@ -32,6 +36,9 @@ class Timescale(Filter):
class Karaoke(Filter):
"""
Filter which filters the vocal track from any song and leaves the instrumental. Best for karaoke as the filter implies.
"""
def __init__(self, *, level: float, mono_level: float, filter_band: float, filter_width: float):
super().__init__()
@ -51,6 +58,7 @@ class Karaoke(Filter):
class Tremolo(Filter):
"""Filter which produces a wavering tone in the music, causing it to sound like the music is changing in volume rapidly."""
def __init__(self, *, frequency: float, depth: float):
super().__init__()
@ -71,6 +79,7 @@ class Tremolo(Filter):
class Vibrato(Filter):
"""Filter which produces a wavering tone in the music, similar to the Tremolo filter, but changes in pitch rather than volume."""
def __init__(self, *, frequency: float, depth: float):

View File

@ -22,6 +22,9 @@ from .utils import ExponentialBackoff, NodeStats
SPOTIFY_URL_REGEX = re.compile(r'https?://open.spotify.com/(?P<type>album|playlist|track)/(?P<id>[a-zA-Z0-9]+)')
class Node:
"""The base class for a node.
This node object represents a Lavalink node.
If you want to enable Spotify searching, pass in a proper Spotify Client ID and Spotify Client Secret"""
def __init__(self, pool, bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: int, password: str, identifier: str, spotify_client_id: Optional[str], spotify_client_secret: Optional[str]):
self._bot = bot
self._host = host
@ -63,10 +66,12 @@ class Node:
@property
def is_connected(self) -> bool:
""""Property which returns whether this node is connected or not"""
return self._websocket is not None and not self._websocket.closed
@property
async def latency(self):
"""Property which returns the latency of the node in milliseconds"""
start_time = time.time()
await self.send(op="ping")
end_time = await self._bot.wait_for(f"node_ping")
@ -74,20 +79,25 @@ class Node:
@property
async def stats(self):
"""Property which returns the node stats at any given time.
Typically, accessing this property is rare due to the fact that Lavalink automatically sends updated node stats every minutes."""
await self.send(op="get-stats")
node_stats = await self._bot.wait_for(f"node_stats")
return node_stats
@property
def players(self):
"""Property which returns a dict containing the guild ID and the player object."""
return self._players
@property
def bot(self):
"""Property which returns the discord.py Bot object linked to this node"""
return self._bot
@property
def pool(self):
"""Property which returns the node pool this node is apart of."""
return self._pool
async def _update_handler(self, data: dict):
@ -154,16 +164,17 @@ class Node:
async def send(self, **data):
if not self.available:
raise exceptions.NodeNotAvailable(f"The node '{self.identifier}' is not currently available.")
await self._websocket.send_str(json.dumps(data))
def get_player(self, guild_id: int):
"""Takes a guild ID as a parameter. Returns a pomice Player object."""
return self._players.get(guild_id, None)
async def connect(self):
"""Initiates a connection with a Lavalink node and adds it to the node pool."""
await self._bot.wait_until_ready()
try:
@ -181,6 +192,7 @@ class Node:
raise exceptions.NodeConnectionFailure(f"The node '{self.identifier}' failed to connect.")
async def disconnect(self):
"""Disconnects a connected Lavalink node and removes it from the node pool. This also destroys any players connected to the node."""
for player in self.players.copy().values():
await player.destroy()
@ -190,9 +202,17 @@ class Node:
self._task.cancel()
async def get_tracks(self, query: str, ctx: commands.Context = None):
"""Fetches tracks from the node's REST api to parse into Lavalink.
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 also pass in a discord.py Context object to get a Context object on any track you search.
"""
if spotify_url_check := SPOTIFY_URL_REGEX.match(query):
if not self._spotify_client_id and not self._spotify_client_secret:
raise exceptions.InvalidSpotifyClientAuthorization("You did not provide proper Spotify client authorization credentials. If you would like to use the Spotify searching feature, please obtain Spotify API credentials here: https://developer.spotify.com/")
search_type = spotify_url_check.group('type')
spotify_id = spotify_url_check.group('id')
if search_type == "playlist":

View File

@ -1,6 +1,10 @@
from discord.ext import commands
class Track:
"""
The base track object. Returns critical track information needed to be parsed by Lavalink.
You can also pass in commands.Context to get a discord.py Context object by passing in a valid Context object when you search for a track.
"""
def __init__(self, track_id: str, info: dict, ctx: commands.Context = None):
@ -27,6 +31,10 @@ class Track:
class Playlist:
"""
The base playlist object. Returns critcal playlist information like the name of the playlist and what tracks are included to be parsed by Lavalink.
You can also pass in commands.Context to get a discord.py Context object by passing in a valid Context object when you search for a track.
"""
def __init__(self, playlist_info: dict, tracks: list, ctx: commands.Context = None):

View File

@ -17,6 +17,10 @@ from typing import Dict, Optional, Any, Union
class Player(VoiceProtocol):
"""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)```
"""
def __init__(self, client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], channel: VoiceChannel):
super().__init__(client=client, channel=channel)
@ -48,6 +52,7 @@ class Player(VoiceProtocol):
@property
def position(self):
"""Property which returns the player's position in a track in milliseconds"""
if not self.is_playing or not self._current:
return 0
@ -65,26 +70,32 @@ class Player(VoiceProtocol):
@property
def is_connected(self):
"""Property which returns whether or not the player is connected to a node."""
return self._is_connected
@property
def is_playing(self):
"""Property which returns whether or not the player is actively playing a track."""
return self._is_connected and self._current is not None
@property
def is_paused(self):
"""Property which returns whether or not the player has a track which is paused or not."""
return self._is_connected and self._paused is True
@property
def node(self):
"""Property which returns what node is associated with this player."""
return self._node
@property
def current(self):
"""Property which returns the current track as a Pomice Track object"""
return self._current
@property
def volume(self):
"""Property which returns the players current volume as an integer"""
return self._volume
@ -125,6 +136,11 @@ class Player(VoiceProtocol):
self._bot.dispatch(f"pomice_{event.name}", event)
async def get_tracks(self, query: str, ctx: commands.Context = None):
"""Fetches tracks from the node's REST api to parse into Lavalink.
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 also pass in a discord.py Context object to get a Context object on any track you search.
"""
return await self._node.get_tracks(query, ctx)
async def connect(self, *, timeout: float, reconnect: bool):
@ -132,8 +148,8 @@ class Player(VoiceProtocol):
self._node._players[self._guild.id] = self
self._is_connected = True
async def stop(self):
"""Stops a currently playing track."""
self._current = None
await self._node.send(op='stop', guildId=str(self._guild.id))
@ -145,19 +161,21 @@ class Player(VoiceProtocol):
self._is_connected = False
del self._node._players[self._guild.id]
async def destroy(self):
"""Disconnects a player and destroys the player instance."""
await self.disconnect()
await self._node.send(op='destroy', guildId=str(self._guild.id))
async def play(self, track: objects.Track, start_position: int = 0):
"""Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
if track.track_id == "spotify":
track: objects.Track = await self._node.get_tracks(f"{track.title} {track.author}")
await self._node.send(op='play', guildId=str(self._guild.id), track=track.track_id, startTime=start_position, endTime=track.length, noReplace=False)
self._current = track
return self._current
async def seek(self, position: int):
async def seek(self, position: float):
"""Seeks to a position in the currently playing track milliseconds"""
if position < 0 or position > self.current.length:
raise exceptions.TrackInvalidPosition(f"Seek position must be between 0 and the track length")
@ -166,16 +184,19 @@ class Player(VoiceProtocol):
return self.position
async def set_pause(self, pause: bool):
"""Sets the pause state of the currently playing track."""
await self._node.send(op='pause', guildId=str(self._guild.id), pause=pause)
self._paused = pause
return self._paused
async def set_volume(self, volume: int):
"""Sets the volume of the player as an integer. Lavalink accepts an amount from 0 to 500."""
await self._node.send(op='volume', guildId=str(self._guild.id), volume=volume)
self._volume = volume
return self._volume
async def set_filter(self, filter: filters.Filter):
"""Sets a filter of the player. Takes a pomice.Filter object. This will only work if you are using the development version of Lavalink."""
await self._node.send(op='filters', guildId=str(self._guild.id), **filter.payload)
await self.seek(self.position)
self._filter = filter

View File

@ -9,6 +9,7 @@ from discord.ext import commands
class NodePool:
"""The base class for the node poll. This holds all the nodes that are to be used by the bot."""
_nodes: dict = {}
@ -17,11 +18,13 @@ class NodePool:
@property
def nodes(self):
"""Property which returns a dict with the node identifier and the Node object."""
return self._nodes
@classmethod
def get_node(self, *, identifier: str = None) -> Node:
"""Fetches a node from the node pool using it's identifier. If no identifier is provided, it will choose a node at random."""
available_nodes = {identifier: node for identifier, node in self._nodes.items()}
if not available_nodes:
raise exceptions.NoNodesAvailable('There are no nodes available.')
@ -33,6 +36,7 @@ class NodePool:
@classmethod
async def create_node(self, bot: typing.Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: str, password: str, identifier: str) -> Node:
"""Creates a Node object to be then added into the node pool. If you like to have Spotify searching capabilites, pass in valid Spotify API credentials."""
if identifier in self._nodes.keys():
raise exceptions.NodeCreationError(f"A node with identifier '{identifier}' already exists.")

View File

@ -57,6 +57,7 @@ class ExponentialBackoff:
return self._randfunc(0, self._base * 2 ** self._exp)
class NodeStats:
"""The base class for the node stats object. Gives critcical information on the node, which is updated every minute."""
def __init__(self, data: dict) -> None: