Merge pull request #4 from vveeps/main

Style changes and typo fixes
This commit is contained in:
Clxud 2021-10-03 16:35:53 -04:00 committed by GitHub
commit 95d01338eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 474 additions and 303 deletions

View File

@ -4,11 +4,10 @@ __version__ = "1.0.4"
__title__ = "pomice" __title__ = "pomice"
__author__ = "cloudwithax" __author__ = "cloudwithax"
from .exceptions import * from .exceptions import *
from .events import * from .events import *
from .filters import * from .filters import *
from .objects import * from .objects import *
from .pool import NodePool from .pool import NodePool
from .node import Node from .node import Node
from .player import Player from .player import Player

View File

@ -3,47 +3,54 @@ from .pool import NodePool
class PomiceEvent: class PomiceEvent:
"""The base class for all events dispatched by a node. """The base class for all events dispatched by a node.
Every event must be formatted within your bots code as a listener. 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: i.e: If you want to listen for when a track starts, the event would be:
```py ```py
@bot.listen @bot.listen
async def on_pomice_track_start(self, event): async def on_pomice_track_start(self, event):
``` ```
""" """
def __init__(self): name = "event"
pass
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""" """Fired when a track has successfully started.
Returns the player associated with the track and the track ID
"""
def __init__(self, data): def __init__(self, data):
super().__init__() super().__init__()
self.name = "track_start" self.name = "track_start"
self.player = NodePool.get_node().get_player(int(data["guildId"])) self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track'] self.track_id = data["track"]
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Pomice.TrackStartEvent track_id={self.track_id}>" return f"<Pomice.TrackStartEvent track_id={self.track_id}>"
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.""" """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__()
self.name = "track_end" self.name = "track_end"
self.player = NodePool.get_node().get_player(int(data["guildId"])) self.player = NodePool.get_node().get_player(int(data["guildId"]))
self.track_id = data['track'] self.track_id = data["track"]
self.reason = data['reason'] self.reason = data["reason"]
def __repr__(self) -> str: def __repr__(self) -> str:
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.""" """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__()
@ -56,8 +63,12 @@ class TrackStuckEvent(PomiceEvent):
def __repr__(self) -> str: def __repr__(self) -> str:
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""" """Fired when a track error has occured.
Returns the player associated with the track along with the error code and exception.
"""
def __init__(self, data): def __init__(self, data):
super().__init__() super().__init__()
@ -70,8 +81,12 @@ class TrackExceptionEvent(PomiceEvent):
def __repr__(self) -> str: def __repr__(self) -> str:
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.""" """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__()
@ -83,15 +98,19 @@ class WebsocketClosedEvent(PomiceEvent):
def __repr__(self) -> str: def __repr__(self) -> str:
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.""" """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.target: str = data['target'] self.target: str = data["target"]
self.ssrc: int = data['ssrc'] self.ssrc: int = data["ssrc"]
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Pomice.WebsocketOpenEvent target={self.target} ssrc={self.ssrc}>" return f"<Pomice.WebsocketOpenEvent target={self.target} ssrc={self.ssrc}>"

View File

@ -15,12 +15,12 @@ class NodeConnectionFailure(NodeException):
class NodeConnectionClosed(NodeException): class NodeConnectionClosed(NodeException):
"""The nodes connection is closed.""" """The node's connection is closed."""
pass pass
class NodeNotAvailable(PomiceException): class NodeNotAvailable(PomiceException):
"""The node is not currently available.""" """The node is currently unavailable."""
pass pass
@ -43,18 +43,22 @@ class FilterInvalidArgument(PomiceException):
"""An invalid argument was passed to a filter.""" """An invalid argument was passed to a filter."""
pass pass
class SpotifyAlbumLoadFailed(PomiceException): class SpotifyAlbumLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load an album""" """The pomice Spotify client was unable to load an album."""
pass pass
class SpotifyTrackLoadFailed(PomiceException): class SpotifyTrackLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load a track""" """The pomice Spotify client was unable to load a track."""
pass pass
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): class InvalidSpotifyClientAuthorization(PomiceException):
"""No Spotify client authorization was provided in order to use the Spotify track search feature""" """No Spotify client authorization was provided for track searching."""
pass pass

View File

@ -1,27 +1,29 @@
from . import exceptions from .exceptions import FilterInvalidArgument
class Filter: class Filter:
def __init__(self): def __init__(self):
self.payload = None self.payload = None
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 """Filter which changes the speed and pitch of a track.
Lavalink dev version due to the filter patch not corresponding with the track time. In short this means that your Do be warned that this filter is bugged as of the lastest Lavalink dev version
track will either end prematurely or end later due to this. This is not the library's fault. 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__()
if speed < 0: if speed < 0:
raise exceptions.FilterInvalidArgument("Timescale speed must be more than 0.") raise FilterInvalidArgument("Timescale speed must be more than 0.")
if pitch < 0: if pitch < 0:
raise exceptions.FilterInvalidArgument("Timescale pitch must be more than 0.") raise FilterInvalidArgument("Timescale pitch must be more than 0.")
if rate < 0: if rate < 0:
raise exceptions.FilterInvalidArgument("Timescale rate must be more than 0.") raise FilterInvalidArgument("Timescale rate must be more than 0.")
self.speed = speed self.speed = speed
self.pitch = pitch self.pitch = pitch
@ -36,11 +38,18 @@ class Timescale(Filter):
class Karaoke(Filter): class Karaoke(Filter):
""" """Filter which filters the vocal track from any song and leaves the instrumental.
Filter which filters the vocal track from any song and leaves the instrumental. Best for karaoke as the filter implies. 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__()
self.level = level self.level = level
@ -54,19 +63,24 @@ class Karaoke(Filter):
"filterWidth": self.filter_width}} "filterWidth": self.filter_width}}
def __repr__(self): def __repr__(self):
return f"<Pomice.KaraokeFilter level={self.level} mono_level={self.mono_level} filter_band={self.filter_band} filter_width={self.filter_width}>" return (
f"<Pomice.KaraokeFilter level={self.level} mono_level={self.mono_level} "
f"filter_band={self.filter_band} filter_width={self.filter_width}>"
)
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.""" """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__()
if frequency < 0: if frequency < 0:
raise exceptions.FilterInvalidArgument("Tremolo frequency must be more than 0.") raise FilterInvalidArgument("Tremolo frequency must be more than 0.")
if depth < 0 or depth > 1: if depth < 0 or depth > 1:
raise exceptions.FilterInvalidArgument("Tremolo depth must be between 0 and 1.") raise FilterInvalidArgument("Tremolo depth must be between 0 and 1.")
self.frequency = frequency self.frequency = frequency
self.depth = depth self.depth = depth
@ -79,15 +93,17 @@ 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.""" """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):
super().__init__() super().__init__()
if frequency < 0 or frequency > 14: if frequency < 0 or frequency > 14:
raise exceptions.FilterInvalidArgument("Vibrato frequency must be between 0 and 14.") raise FilterInvalidArgument("Vibrato frequency must be between 0 and 14.")
if depth < 0 or depth > 1: if depth < 0 or depth > 1:
raise exceptions.FilterInvalidArgument("Vibrato depth must be between 0 and 1.") raise FilterInvalidArgument("Vibrato depth must be between 0 and 1.")
self.frequency = frequency self.frequency = frequency
self.depth = depth self.depth = depth
@ -96,4 +112,4 @@ class Vibrato(Filter):
"depth": self.depth}} "depth": self.depth}}
def __repr__(self): def __repr__(self):
return f"<Pomice.VibratoFilter frequency={self.frequency} depth={self.depth}>" return f"<Pomice.VibratoFilter frequency={self.frequency} depth={self.depth}>"

View File

@ -1,31 +1,50 @@
from os import strerror
import aiohttp
import discord
import asyncio import asyncio
import typing
import json import json
import re
import socket import socket
import time import time
import re from typing import Optional, Type
from discord.ext import commands
from typing import Optional, Union
from urllib.parse import quote from urllib.parse import quote
from . import spotify
from . import events import aiohttp
from . import exceptions import discord
from . import objects from discord.ext import commands
from . import __version__
from . import __version__, objects, spotify
from .exceptions import (
InvalidSpotifyClientAuthorization,
NodeConnectionFailure,
NodeNotAvailable,
SpotifyAlbumLoadFailed,
SpotifyPlaylistLoadFailed,
SpotifyTrackLoadFailed,
TrackLoadError
)
from .spotify import SpotifyException
from .utils import ExponentialBackoff, NodeStats 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. """The base class for a node.
This node object represents a Lavalink 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""" 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: Type[discord.Client],
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
self._port = port self._port = port
@ -52,17 +71,24 @@ class Node:
self._players = {} self._players = {}
self._spotify_client_id: str = spotify_client_id self._spotify_client_id = spotify_client_id
self._spotify_client_secret: str = spotify_client_secret self._spotify_client_secret = spotify_client_secret
if self._spotify_client_id and self._spotify_client_secret: if self._spotify_client_id and self._spotify_client_secret:
self._spotify_client: spotify.Client = spotify.Client(self._spotify_client_id, self._spotify_client_secret) self._spotify_client = spotify.Client(
self._spotify_http_client: spotify.HTTPClient = spotify.HTTPClient(self._spotify_client_id, self._spotify_client_secret) self._spotify_client_id, self._spotify_client_secret
)
self._spotify_http_client = spotify.HTTPClient(
self._spotify_client_id, self._spotify_client_secret
)
self._bot.add_listener(self._update_handler, "on_socket_response") self._bot.add_listener(self._update_handler, "on_socket_response")
def __repr__(self): def __repr__(self):
return f"<Pomice.node ws_uri={self._websocket_uri} rest_uri={self._rest_uri} player_count={len(self._players)}>" return (
f"<Pomice.node ws_uri={self._websocket_uri} rest_uri={self._rest_uri} "
f"player_count={len(self._players)}>"
)
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
@ -74,15 +100,14 @@ class Node:
"""Property which returns the latency of the node in milliseconds""" """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("node_ping")
return (end_time - start_time) * 1000 return (end_time - start_time) * 1000
@property @property
async def stats(self): async def stats(self):
"""Property which returns the node stats at any given time. """Property which returns the node stats."""
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("node_stats")
return node_stats return node_stats
@property @property
@ -92,12 +117,16 @@ class Node:
@property @property
def bot(self): def bot(self):
"""Property which returns the discord.py Bot object linked to this node""" """Property which returns the discord.py client linked to this node"""
return self._bot return self._bot
@property
def player_count(self):
return len(self.players)
@property @property
def pool(self): def pool(self):
"""Property which returns the node pool this node is apart of.""" """Property which returns the node pool this node is a part of."""
return self._pool return self._pool
async def _update_handler(self, data: dict): async def _update_handler(self, data: dict):
@ -106,9 +135,7 @@ class Node:
if not data: if not data:
return return
if data["t"] == "VOICE_SERVER_UPDATE": if data["t"] == "VOICE_SERVER_UPDATE":
guild_id = int(data["d"]["guild_id"]) guild_id = int(data["d"]["guild_id"])
try: try:
player = self._players[guild_id] player = self._players[guild_id]
@ -117,7 +144,6 @@ class Node:
return return
elif data["t"] == "VOICE_STATE_UPDATE": elif data["t"] == "VOICE_STATE_UPDATE":
if int(data["d"]["user_id"]) != self._bot.user.id: if int(data["d"]["user_id"]) != self._bot.user.id:
return return
@ -128,9 +154,6 @@ class Node:
except KeyError: except KeyError:
return return
else:
return
async def _listen(self): async def _listen(self):
backoff = ExponentialBackoff(base=7) backoff = ExponentialBackoff(base=7)
@ -145,27 +168,27 @@ class Node:
else: else:
self._bot.loop.create_task(self._handle_payload(msg.json())) self._bot.loop.create_task(self._handle_payload(msg.json()))
async def _handle_payload(self, data: dict) -> None: async def _handle_payload(self, data: dict):
op = data.get('op', None) op = data.get("op", None)
if not op: if not op:
return return
if op == 'stats': if op == "stats":
self._stats = NodeStats(data) self._stats = NodeStats(data)
return return
if not (player := self._players.get(int(data['guildId']))): if not (player := self._players.get(int(data["guildId"]))):
return return
if op == 'event': if op == "event":
await player._dispatch_event(data) await player._dispatch_event(data)
elif op == 'playerUpdate': elif op == "playerUpdate":
await player._update_state(data) await player._update_state(data)
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 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))
@ -173,26 +196,35 @@ class Node:
"""Takes a guild ID as a parameter. Returns a pomice Player object.""" """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.""" """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:
self._websocket = await self._session.ws_connect(self._websocket_uri, headers=self._headers, heartbeat=60) self._websocket = await self._session.ws_connect(
self._websocket_uri, headers=self._headers, heartbeat=60
)
self._task = self._bot.loop.create_task(self._listen()) self._task = self._bot.loop.create_task(self._listen())
self._pool._nodes[self._identifier] = self self._pool._nodes[self._identifier] = self
self.available = True self.available = True
return self return self
except aiohttp.WSServerHandshakeError: except aiohttp.WSServerHandshakeError:
raise exceptions.NodeConnectionFailure(f"The password for node '{self.identifier}' is invalid.") raise NodeConnectionFailure(
f"The password for node '{self.identifier}' is invalid."
)
except aiohttp.InvalidURL: except aiohttp.InvalidURL:
raise exceptions.NodeConnectionFailure(f"The URI for node '{self.identifier}' is invalid.") raise NodeConnectionFailure(
f"The URI for node '{self.identifier}' is invalid."
)
except socket.gaierror: except socket.gaierror:
raise exceptions.NodeConnectionFailure(f"The node '{self.identifier}' failed to connect.") raise 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.""" """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()
@ -200,91 +232,166 @@ class Node:
del self._pool.nodes[self._identifier] del self._pool.nodes[self._identifier]
self.available = False self.available = False
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. """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. If you passed in Spotify API credentials, you can also pass in a
You can also pass in a discord.py Context object to get a Context object on any track you search. 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: 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/") raise 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")
search_type = spotify_url_check.group('type')
spotify_id = spotify_url_check.group('id')
if search_type == "playlist": if search_type == "playlist":
results: spotify.Playlist = spotify.Playlist(client=self._spotify_client, data=await self._spotify_http_client.get_playlist(spotify_id)) results = spotify.Playlist(
client=self._spotify_client,
data=await self._spotify_http_client.get_playlist(spotify_id)
)
try: try:
search_tracks = await results.get_all_tracks() search_tracks = await results.get_all_tracks()
tracks = [ tracks = [
objects.Track( objects.Track(
track_id=track.id, track_id=track.id,
ctx=ctx, ctx=ctx,
spotify=True, spotify=True,
info={'title': track.name or 'Unknown', 'author': ', '.join(artist.name for artist in track.artists) or 'Unknown', info={
'length': track.duration or 0, 'identifier': track.id or 'Unknown', 'uri': track.url or 'spotify', "title": track.name or "Unknown",
'isStream': False, 'isSeekable': False, 'position': 0, 'thumbnail': track.images[0].url if track.images else None}, "author": ", ".join(
artist.name for artist in track.artists
) or "Unknown",
"length": track.duration or 0,
"identifier": track.id or "Unknown",
"uri": track.url or "spotify",
"isStream": False,
"isSeekable": False,
"position": 0,
"thumbnail": track.images[0].url if track.images else None
},
) for track in search_tracks ) for track in search_tracks
] ]
return objects.Playlist(playlist_info={"name": results.name, "selectedTrack": tracks[0]}, tracks=tracks, ctx=ctx, spotify=True)
except: return objects.Playlist(
raise exceptions.SpotifyPlaylistLoadFailed(f"Unable to find results for {query}") playlist_info={"name": results.name, "selectedTrack": tracks[0]},
tracks=tracks,
ctx=ctx,
spotify=True
)
except SpotifyException:
raise SpotifyPlaylistLoadFailed(
f"Unable to find results for {query}"
)
elif search_type == "album": elif search_type == "album":
results: spotify.Album = await self._spotify_client.get_album(spotify_id=spotify_id) results = await self._spotify_client.get_album(spotify_id=spotify_id)
try: try:
search_tracks = await results.get_all_tracks() search_tracks = await results.get_all_tracks()
tracks = [ tracks = [
objects.Track( objects.Track(
track_id=track.id, track_id=track.id,
ctx=ctx, ctx=ctx,
spotify=True, spotify=True,
info={'title': track.name or 'Unknown', 'author': ', '.join(artist.name for artist in track.artists) or 'Unknown', info={
'length': track.duration or 0, 'identifier': track.id or 'Unknown', 'uri': track.url or 'spotify', "title": track.name or "Unknown",
'isStream': False, 'isSeekable': False, 'position': 0, 'thumbnail': track.images[0].url if track.images else None}, "author": ", ".join(
artist.name for artist in track.artists
) or "Unknown",
"length": track.duration or 0,
"identifier": track.id or "Unknown",
"uri": track.url or "spotify",
"isStream": False,
"isSeekable": False,
"position": 0,
"thumbnail": track.images[0].url if track.images else None
},
) for track in search_tracks ) for track in search_tracks
] ]
return objects.Playlist(playlist_info={"name": results.name, "selectedTrack": tracks[0]}, tracks=tracks, ctx=ctx, spotify=True) return objects.Playlist(
except: playlist_info={"name": results.name, "selectedTrack": tracks[0]},
raise exceptions.SpotifyAlbumLoadFailed(f"Unable to find results for {query}") tracks=tracks,
ctx=ctx,
spotify=True
)
except SpotifyException:
raise SpotifyAlbumLoadFailed(f"Unable to find results for {query}")
elif search_type == 'track': elif search_type == 'track':
try: try:
results: spotify.Track = await self._spotify_client.get_track(spotify_id=spotify_id) results = await self._spotify_client.get_track(spotify_id=spotify_id)
return [objects.Track(
return [
objects.Track(
track_id=results.id, track_id=results.id,
ctx=ctx, ctx=ctx,
spotify=True, spotify=True,
info={'title': results.name or 'Unknown', 'author': ', '.join(artist.name for artist in results.artists) or 'Unknown', info={
'length': results.duration or 0, 'identifier': results.id or 'Unknown', 'uri': results.url or 'spotify', "title": results.name or "Unknown",
'isStream': False, 'isSeekable': False, 'position': 0, 'thumbnail': results.images[0].url if results.images else None},)] "author": ", ".join(
except: artist.name for artist in results.artists
raise exceptions.SpotifyTrackLoadFailed(f"Unable to find results for {query}") ) or "Unknown",
"length": results.duration or 0,
"identifier": results.id or "Unknown",
"uri": results.url or "spotify",
"isStream": False,
"isSeekable": False,
"position": 0,
"thumbnail": results.images[0].url if results.images else None
},
)
]
except SpotifyException:
raise SpotifyTrackLoadFailed(f"Unable to find results for {query}")
else: else:
async with self._session.get(url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", headers={"Authorization": self._password}) as response: async with self._session.get(
url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}",
headers={"Authorization": self._password}
) as response:
data = await response.json() data = await response.json()
load_type = data.get("loadType") load_type = data.get("loadType")
if not load_type: if not load_type:
raise exceptions.TrackLoadError("There was an error while trying to load this track.") raise TrackLoadError("There was an error while trying to load this track.")
elif load_type == "LOAD_FAILED": elif load_type == "LOAD_FAILED":
raise exceptions.TrackLoadError(f"There was an error of severity '{data['severity']}' while loading tracks.\n\n{data['cause']}") raise TrackLoadError(
f"There was an error of severity '{data['severity']}' "
f"while loading tracks.\n\n{data['cause']}"
)
elif load_type == "NO_MATCHES": elif load_type == "NO_MATCHES":
return None return None
elif load_type == "PLAYLIST_LOADED": elif load_type == "PLAYLIST_LOADED":
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"], ctx=ctx) return objects.Playlist(
playlist_info=data["playlistInfo"],
tracks=data["tracks"],
ctx=ctx
)
elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED": elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED":
return [objects.Track(track_id=track["track"], info=track["info"], ctx=ctx) for track in data["tracks"]] return [
objects.Track(
track_id=track["track"],
info=track["info"],
ctx=ctx
)
for track in data["tracks"]
]

View File

@ -1,13 +1,20 @@
from typing import Optional
from discord.ext import commands from discord.ext import commands
class Track: class Track:
""" """The base track object. Returns critical track information needed for parsing by Lavalink.
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 in your track.
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, spotify: bool = False): def __init__(
self,
track_id: str,
info: dict,
ctx: Optional[commands.Context] = None,
spotify: bool = False
):
self.track_id = track_id self.track_id = track_id
self.info = info self.info = info
self.spotify = spotify self.spotify = spotify
@ -15,9 +22,8 @@ class Track:
self.title = info.get("title") self.title = info.get("title")
self.author = info.get("author") self.author = info.get("author")
self.length = info.get("length") self.length = info.get("length")
if ctx: self.ctx = ctx
self.ctx: commands.Context = ctx self.requester = self.ctx.author if ctx else None
self.requester = self.ctx.author
self.identifier = info.get("identifier") self.identifier = info.get("identifier")
self.uri = info.get("uri") self.uri = info.get("uri")
self.is_stream = info.get("isStream") self.is_stream = info.get("isStream")
@ -32,13 +38,18 @@ class Track:
class Playlist: class Playlist:
""" """The base playlist object.
The base playlist object. Returns critcal playlist information like the name of the playlist and what tracks are included to be parsed by Lavalink. Returns critical playlist information needed for parsing 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. You can also pass in commands.Context to get a discord.py Context object in your tracks.
""" """
def __init__(self, playlist_info: dict, tracks: list, ctx: commands.Context = None, spotify: bool = False): def __init__(
self,
playlist_info: dict,
tracks: list,
ctx: Optional[commands.Context] = None,
spotify: bool = False
):
self.playlist_info = playlist_info self.playlist_info = playlist_info
self.tracks_raw = tracks self.tracks_raw = tracks
self.spotify = spotify self.spotify = spotify
@ -46,14 +57,18 @@ class Playlist:
self.name = playlist_info.get("name") self.name = playlist_info.get("name")
self.selected_track = playlist_info.get("selectedTrack") self.selected_track = playlist_info.get("selectedTrack")
if self.spotify == True: if self.spotify:
self.tracks = tracks self.tracks = tracks
else: else:
self.tracks = [Track(track_id=track["track"], info=track["info"], ctx=ctx) for track in self.tracks_raw] self.tracks = [
Track(track_id=track["track"], info=track["info"], ctx=ctx)
for track in self.tracks_raw
]
self.track_count = len(self.tracks)
def __str__(self): def __str__(self):
return self.name return self.name
def __repr__(self): def __repr__(self):
return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>" return f"<Pomice.playlist name={self.name!r} track_count={len(self.tracks)}>"

View File

@ -1,62 +1,60 @@
import time import time
from typing import Any, Dict, Type
import discord import discord
from discord import VoiceChannel, VoiceProtocol
from . import exceptions
from . import filters
from . import objects
from .node import Node
from .pool import NodePool
from . import events
import discord
from discord import VoiceProtocol, VoiceChannel
from discord.ext import commands from discord.ext import commands
from typing import Dict, Optional, Any, Union
from . import events, filters, objects
from .exceptions import TrackInvalidPosition
from .pool import NodePool
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. """The base player class for Pomice.
i.e: ```py In order to initiate a player, you must pass it in as a cls when you connect to a channel.
await ctx.author.voice.channel.connect(cls=pomice.Player)``` 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: Type[discord.Client], channel: VoiceChannel):
super().__init__(client=client, channel=channel) super().__init__(client=client, channel=channel)
self.client: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client self.client = client
self._bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient] = client self.bot = client
self.channel: VoiceChannel = channel self.channel = channel
self._guild: discord.Guild = channel.guild self.guild: discord.Guild = self.channel.guild
self._dj: discord.Member = None self.dj: discord.Member = None
self._node: Node = NodePool.get_node() self.node = NodePool.get_node()
self._current: objects.Track = None self.current: objects.Track = None
self._filter: filters.Filter = None self.filter: filters.Filter = None
self._volume: int = 100 self.volume = 100
self._paused: bool = False self.paused = False
self._is_connected: bool = False self.is_connected = False
self._position: int = 0 self._position = 0
self._last_update: int = 0 self._last_position = 0
self._current_track_id = None self._last_update = 0
self._voice_server_update_data = {}
self._voice_state = {}
def __repr__(self): def __repr__(self):
return f"<Pomice.player bot={self._bot} guildId={self._guild.id} is_connected={self.is_connected} is_playing={self.is_playing}>" return (
f"<Pomice.player bot={self.bot} guildId={self.guild.id} "
f"is_connected={self.is_connected} is_playing={self.is_playing}>"
)
@property @property
def position(self): def position(self):
"""Property which returns the player's position in a track in milliseconds""" """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
if self.is_paused: if self.is_paused:
return min(self._last_position, self._current.length) return min(self._last_position, self.current.length)
difference = (time.time() * 1000) - self._last_update difference = (time.time() * 1000) - self._last_update
position = self._last_position + difference position = self._last_position + difference
@ -66,141 +64,140 @@ class Player(VoiceProtocol):
return min(position, self.current.length) return min(position, self.current.length)
@property
def is_connected(self):
"""Property which returns whether or not the player is connected to a node."""
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.""" """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.""" """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
@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
async def _update_state(self, data: dict): async def _update_state(self, data: dict):
state: dict = data.get("state")
state: dict = data.get('state')
self._last_update = time.time() * 1000 self._last_update = time.time() * 1000
self._is_connected = state.get('connected') self.is_connected = state.get("connected")
self._last_position = state.get('position') self._last_position = state.get("position")
async def _dispatch_voice_update(self, voice_data: Dict[str, Any]) -> None: async def _dispatch_voice_update(self, voice_data: Dict[str, Any]):
if {'sessionId', 'event'} != self._voice_server_update_data.keys(): if {"sessionId", "event"} != self._voice_state.keys():
return return
await self._node.send( await self.node.send(
op='voiceUpdate', op="voiceUpdate",
guildId=str(self._guild.id), guildId=str(self.guild.id),
**voice_data **voice_data
) )
async def _voice_server_update(self, data: dict): async def _voice_server_update(self, data: dict):
self._voice_server_update_data.update({'event': data}) self._voice_state.update({"event": data})
await self._dispatch_voice_update(self._voice_server_update_data) await self._dispatch_voice_update(self._voice_state)
async def _voice_state_update(self, data: dict): async def _voice_state_update(self, data: dict):
self._voice_server_update_data.update({'sessionId': data.get('session_id')}) self._voice_state.update({"sessionId": data.get("session_id")})
if not (channel_id := data.get('channel_id')): if not (channel_id := data.get("channel_id")):
self.channel = None self.channel = None
self._voice_server_update_data.clear() self._voice_state.clear()
return return
self.channel = self._guild.get_channel(int(channel_id)) self.channel = self.guild.get_channel(int(channel_id))
await self._dispatch_voice_update({**self._voice_server_update_data, "event": data}) await self._dispatch_voice_update({**self._voice_state, "event": data})
async def _dispatch_event(self, data: dict): async def _dispatch_event(self, data: dict):
event_type = data.get('type') event_type = data.get("type")
event = getattr(events, event_type, None) event = getattr(events, event_type, None)
event = event(data) event = event(data)
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. """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. If you passed in Spotify API credentials, you can also pass in a Spotify URL of a playlist,
You can also pass in a discord.py Context object to get a Context object on any track you search. 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):
await self._guild.change_voice_state(channel=self.channel) await self.guild.change_voice_state(channel=self.channel)
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.""" """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))
async def disconnect(self, *, force: bool = False): async def disconnect(self, *, force: bool = False):
await self.stop() await self.stop()
await self._guild.change_voice_state(channel=None) await self.guild.change_voice_state(channel=None)
self.cleanup() self.cleanup()
self.channel = None self.channel = None
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.""" """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.""" """Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
if track.spotify == True: if track.spotify:
spotify_track: objects.Track = await self._node.get_tracks(f"ytmsearch:{track.title} {track.author} audio") spotify_track: objects.Track = (await self.node.get_tracks(
await self._node.send(op='play', guildId=str(self._guild.id), track=spotify_track[0].track_id, startTime=start_position, endTime=spotify_track[0].length, noReplace=False) f"ytmsearch:{track.title} {track.author} audio"
))[0]
await self.node.send(
op="play",
guildId=str(self.guild.id),
track=spotify_track.track_id,
startTime=start_position,
endTime=spotify_track.length,
noReplace=False
)
else: else:
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(
self._current = track op="play",
return self._current 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: float): async def seek(self, position: float):
"""Seeks to a position in the currently playing track milliseconds""" """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 TrackInvalidPosition(
f"Seek position must be between 0 and the track length"
)
await self._node.send(op='seek', guildId=str(self._guild.id), position=position) await self.node.send(op="seek", guildId=str(self.guild.id), position=position)
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.""" """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.""" """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.""" """Sets a filter of the player. Takes a pomice.Filter object.
await self._node.send(op='filters', guildId=str(self._guild.id), **filter.payload) 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) await self.seek(self.position)
self._filter = filter self.filter = filter
return filter return filter

View File

@ -1,51 +1,68 @@
import discord
import typing
import random import random
from typing import Optional, Type
from . import exceptions import discord
from .exceptions import NodeCreationError, NoNodesAvailable
from .node import Node from .node import Node
from typing import Optional
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.""" """The base class for the node pool.
This holds all the nodes that are to be used by the bot.
"""
_nodes: dict = {} _nodes: dict = {}
def __repr__(self): def __repr__(self):
return f"<Pomice.NodePool node_count={len(self._nodes.values())}>" return f"<Pomice.NodePool node_count={self.node_count}>"
@property @property
def nodes(self): def nodes(self):
"""Property which returns a dict with the node identifier and the Node object.""" """Property which returns a dict with the node identifier and the Node object."""
return self._nodes return self._nodes
@property
def node_count(self):
return len(self._nodes.values())
@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.""" """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 NoNodesAvailable('There are no nodes available.')
if identifier is None: if identifier is None:
return random.choice(list(available_nodes.values())) return random.choice(list(available_nodes.values()))
return available_nodes.get(identifier, None) return available_nodes.get(identifier, None)
@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, spotify_client_id: Optional[str] = None, spotify_client_secret: Optional[str] = None) -> 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.")
node = Node(pool=self, bot=bot, host=host, port=port, password=password, identifier=identifier, spotify_client_id=spotify_client_id, spotify_client_secret=spotify_client_secret) @classmethod
async def create_node(
self,
bot: Type[discord.Client],
host: str,
port: str,
password: str,
identifier: str,
spotify_client_id: Optional[str] = None,
spotify_client_secret: Optional[str] = None
) -> Node:
"""Creates a Node object to be then added into the node pool.
For Spotify searching capabilites, pass in valid Spotify API credentials.
"""
if identifier in self._nodes.keys():
raise NodeCreationError(f"A node with identifier '{identifier}' already exists.")
node = Node(
pool=self, bot=bot, host=host, port=port, password=password,
identifier=identifier, spotify_client_id=spotify_client_id,
spotify_client_secret=spotify_client_secret
)
await node.connect() await node.connect()
self._nodes[node._identifier] = node self._nodes[node._identifier] = node
return node return node

View File

@ -21,7 +21,6 @@ DEALINGS IN THE SOFTWARE.
import random import random
import time import time
__all__ = [ __all__ = [
'ExponentialBackoff', 'ExponentialBackoff',
'PomiceStats' 'PomiceStats'
@ -56,6 +55,7 @@ class ExponentialBackoff:
self._exp = min(self._exp + 1, self._max) self._exp = min(self._exp + 1, self._max)
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.""" """The base class for the node stats object. Gives critcical information on the node, which is updated every minute."""
@ -78,5 +78,3 @@ class NodeStats:
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<Pomice.NodeStats total_players={self.players_total} playing_active={self.players_active}>' return f'<Pomice.NodeStats total_players={self.players_total} playing_active={self.players_active}>'

View File

@ -3,7 +3,6 @@ import setuptools
with open("README.md") as f: with open("README.md") as f:
readme = f.read() readme = f.read()
setuptools.setup( setuptools.setup(
name="pomice", name="pomice",
author="cloudwithax", author="cloudwithax",
@ -30,4 +29,4 @@ setuptools.setup(
], ],
python_requires='>=3.8', python_requires='>=3.8',
keywords=['pomice', 'lavalink', "discord.py"], keywords=['pomice', 'lavalink', "discord.py"],
) )