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""" """Pomice wrapper for Lavalink, made possible by cloudwithax 2021"""
__version__ = "1.0.1" __version__ = "1.0.3"
__title__ = "pomice" __title__ = "pomice"
__author__ = "cloudwithax" __author__ = "cloudwithax"

View File

@ -2,12 +2,21 @@ from .pool import NodePool
class PomiceEvent: 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): def __init__(self):
pass pass
name = 'event' name = 'event'
class TrackStartEvent(PomiceEvent): 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): def __init__(self, data):
super().__init__() super().__init__()
@ -21,6 +30,7 @@ class TrackStartEvent(PomiceEvent):
class TrackEndEvent(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): def __init__(self, data):
super().__init__() super().__init__()
@ -33,6 +43,7 @@ class TrackEndEvent(PomiceEvent):
return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>" return f"<Pomice.TrackEndEvent track_id={self.track_id} reason={self.reason}>"
class TrackStuckEvent(PomiceEvent): 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): def __init__(self, data):
super().__init__() super().__init__()
@ -46,6 +57,7 @@ class TrackStuckEvent(PomiceEvent):
return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>" return f"<Pomice.TrackStuckEvent track_id={self.track_id} threshold={self.threshold}>"
class TrackExceptionEvent(PomiceEvent): 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): def __init__(self, data):
super().__init__() super().__init__()
@ -59,11 +71,11 @@ class TrackExceptionEvent(PomiceEvent):
return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}" return f"<Pomice.TrackExceptionEvent> error={self.error} exeception={self.exception}"
class WebsocketClosedEvent(PomiceEvent): 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): def __init__(self, data):
super().__init__() super().__init__()
self.name = "websocket_closed" self.name = "websocket_closed"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.reason = data["reason"] self.reason = data["reason"]
self.code = data["code"] self.code = data["code"]
@ -72,11 +84,11 @@ class WebsocketClosedEvent(PomiceEvent):
return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>" return f"<Pomice.WebsocketClosedEvent reason={self.reason} code={self.code}>"
class WebsocketOpenEvent(PomiceEvent): 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): def __init__(self, data):
super().__init__() super().__init__()
self.name = "websocket_open" self.name = "websocket_open"
self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.target: str = data['target'] self.target: str = data['target']
self.ssrc: int = data['ssrc'] self.ssrc: int = data['ssrc']

View File

@ -54,3 +54,7 @@ class SpotifyTrackLoadFailed(PomiceException):
class SpotifyPlaylistLoadFailed(PomiceException): class SpotifyPlaylistLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load a playlist""" """The pomice Spotify client was unable to load a playlist"""
pass 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): 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): def __init__(self, *, speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0):
super().__init__() super().__init__()
@ -32,6 +36,9 @@ class Timescale(Filter):
class Karaoke(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): def __init__(self, *, level: float, mono_level: float, filter_band: float, filter_width: float):
super().__init__() super().__init__()
@ -51,6 +58,7 @@ class Karaoke(Filter):
class Tremolo(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): def __init__(self, *, frequency: float, depth: float):
super().__init__() super().__init__()
@ -71,6 +79,7 @@ class Tremolo(Filter):
class Vibrato(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): 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]+)') SPOTIFY_URL_REGEX = re.compile(r'https?://open.spotify.com/(?P<type>album|playlist|track)/(?P<id>[a-zA-Z0-9]+)')
class Node: 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]): 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._bot = bot
self._host = host self._host = host
@ -63,10 +66,12 @@ class Node:
@property @property
def is_connected(self) -> bool: 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 return self._websocket is not None and not self._websocket.closed
@property @property
async def latency(self): async def latency(self):
"""Property which returns the latency of the node in milliseconds"""
start_time = time.time() start_time = time.time()
await self.send(op="ping") await self.send(op="ping")
end_time = await self._bot.wait_for(f"node_ping") end_time = await self._bot.wait_for(f"node_ping")
@ -74,20 +79,25 @@ class Node:
@property @property
async def stats(self): 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") await self.send(op="get-stats")
node_stats = await self._bot.wait_for(f"node_stats") node_stats = await self._bot.wait_for(f"node_stats")
return node_stats return node_stats
@property @property
def players(self): def players(self):
"""Property which returns a dict containing the guild ID and the player object."""
return self._players return self._players
@property @property
def bot(self): def bot(self):
"""Property which returns the discord.py Bot object linked to this node"""
return self._bot return self._bot
@property @property
def pool(self): def pool(self):
"""Property which returns the node pool this node is apart of."""
return self._pool return self._pool
async def _update_handler(self, data: dict): async def _update_handler(self, data: dict):
@ -154,16 +164,17 @@ class Node:
async def send(self, **data): async def send(self, **data):
if not self.available: if not self.available:
raise exceptions.NodeNotAvailable(f"The node '{self.identifier}' is not currently available.") raise exceptions.NodeNotAvailable(f"The node '{self.identifier}' is not currently available.")
await self._websocket.send_str(json.dumps(data)) await self._websocket.send_str(json.dumps(data))
def get_player(self, guild_id: int): 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) return self._players.get(guild_id, None)
async def connect(self): async def connect(self):
"""Initiates a connection with a Lavalink node and adds it to the node pool."""
await self._bot.wait_until_ready() await self._bot.wait_until_ready()
try: try:
@ -181,6 +192,7 @@ class Node:
raise exceptions.NodeConnectionFailure(f"The node '{self.identifier}' failed to connect.") raise exceptions.NodeConnectionFailure(f"The node '{self.identifier}' failed to connect.")
async def disconnect(self): 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(): for player in self.players.copy().values():
await player.destroy() await player.destroy()
@ -190,9 +202,17 @@ class Node:
self._task.cancel() self._task.cancel()
async def get_tracks(self, query: str, ctx: commands.Context = None): 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 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') search_type = spotify_url_check.group('type')
spotify_id = spotify_url_check.group('id') spotify_id = spotify_url_check.group('id')
if search_type == "playlist": if search_type == "playlist":

View File

@ -1,6 +1,10 @@
from discord.ext import commands from discord.ext import commands
class Track: 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): def __init__(self, track_id: str, info: dict, ctx: commands.Context = None):
@ -27,6 +31,10 @@ class Track:
class Playlist: 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): 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): 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): def __init__(self, client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], channel: VoiceChannel):
super().__init__(client=client, channel=channel) super().__init__(client=client, channel=channel)
@ -48,6 +52,7 @@ class Player(VoiceProtocol):
@property @property
def position(self): def position(self):
"""Property which returns the player's position in a track in milliseconds"""
if not self.is_playing or not self._current: if not self.is_playing or not self._current:
return 0 return 0
@ -65,26 +70,32 @@ class Player(VoiceProtocol):
@property @property
def is_connected(self): def is_connected(self):
"""Property which returns whether or not the player is connected to a node."""
return self._is_connected return self._is_connected
@property @property
def is_playing(self): 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 return self._is_connected and self._current is not None
@property @property
def is_paused(self): 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 return self._is_connected and self._paused is True
@property @property
def node(self): def node(self):
"""Property which returns what node is associated with this player."""
return self._node return self._node
@property @property
def current(self): def current(self):
"""Property which returns the current track as a Pomice Track object"""
return self._current return self._current
@property @property
def volume(self): def volume(self):
"""Property which returns the players current volume as an integer"""
return self._volume return self._volume
@ -125,6 +136,11 @@ class Player(VoiceProtocol):
self._bot.dispatch(f"pomice_{event.name}", event) self._bot.dispatch(f"pomice_{event.name}", event)
async def get_tracks(self, query: str, ctx: commands.Context = None): 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) return await self._node.get_tracks(query, ctx)
async def connect(self, *, timeout: float, reconnect: bool): async def connect(self, *, timeout: float, reconnect: bool):
@ -132,8 +148,8 @@ class Player(VoiceProtocol):
self._node._players[self._guild.id] = self self._node._players[self._guild.id] = self
self._is_connected = True self._is_connected = True
async def stop(self): async def stop(self):
"""Stops a currently playing track."""
self._current = None self._current = None
await self._node.send(op='stop', guildId=str(self._guild.id)) await self._node.send(op='stop', guildId=str(self._guild.id))
@ -145,19 +161,21 @@ class Player(VoiceProtocol):
self._is_connected = False self._is_connected = False
del self._node._players[self._guild.id] del self._node._players[self._guild.id]
async def destroy(self): async def destroy(self):
"""Disconnects a player and destroys the player instance."""
await self.disconnect() await self.disconnect()
await self._node.send(op='destroy', guildId=str(self._guild.id)) await self._node.send(op='destroy', guildId=str(self._guild.id))
async def play(self, track: objects.Track, start_position: int = 0): 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": if track.track_id == "spotify":
track: objects.Track = await self._node.get_tracks(f"{track.title} {track.author}") 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) 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 self._current = track
return self._current 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: if position < 0 or position > self.current.length:
raise exceptions.TrackInvalidPosition(f"Seek position must be between 0 and the track 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 return self.position
async def set_pause(self, pause: bool): 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) await self._node.send(op='pause', guildId=str(self._guild.id), pause=pause)
self._paused = pause self._paused = pause
return self._paused return self._paused
async def set_volume(self, volume: int): 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) await self._node.send(op='volume', guildId=str(self._guild.id), volume=volume)
self._volume = volume self._volume = volume
return self._volume return self._volume
async def set_filter(self, filter: filters.Filter): 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._node.send(op='filters', guildId=str(self._guild.id), **filter.payload)
await self.seek(self.position) await self.seek(self.position)
self._filter = filter self._filter = filter

View File

@ -9,6 +9,7 @@ from discord.ext import commands
class NodePool: class NodePool:
"""The base class for the node poll. This holds all the nodes that are to be used by the bot."""
_nodes: dict = {} _nodes: dict = {}
@ -17,11 +18,13 @@ class NodePool:
@property @property
def nodes(self): def nodes(self):
"""Property which returns a dict with the node identifier and the Node object."""
return self._nodes return self._nodes
@classmethod @classmethod
def get_node(self, *, identifier: str = None) -> Node: 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()} available_nodes = {identifier: node for identifier, node in self._nodes.items()}
if not available_nodes: if not available_nodes:
raise exceptions.NoNodesAvailable('There are no nodes available.') raise exceptions.NoNodesAvailable('There are no nodes available.')
@ -33,6 +36,7 @@ class NodePool:
@classmethod @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: 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(): if identifier in self._nodes.keys():
raise exceptions.NodeCreationError(f"A node with identifier '{identifier}' already exists.") 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) return self._randfunc(0, self._base * 2 ** self._exp)
class NodeStats: 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: def __init__(self, data: dict) -> None: