Revert "Remove all Spotify client code in preparation for 1.1.8"

This reverts commit ab708a1cfb.
This commit is contained in:
cloudwithax 2022-10-06 19:31:38 -04:00
parent 827ab0a1ef
commit 3687f9b33a
9 changed files with 392 additions and 33 deletions

View File

@ -18,15 +18,15 @@ if not discord.version_info.major >= 2:
"using 'pip install discord.py'" "using 'pip install discord.py'"
) )
__version__ = "1.1.8a" __version__ = "1.1.7"
__title__ = "pomice" __title__ = "pomice"
__author__ = "cloudwithax" __author__ = "cloudwithax"
from .enums import * from .enums import SearchType
from .events import * from .events import *
from .exceptions import * from .exceptions import *
from .filters import * from .filters import *
from .objects import * from .objects import *
from .player import * from .player import Player
from .pool import * from .pool import *
from .queue import * from .queue import *

View File

@ -52,6 +52,25 @@ class FilterTagAlreadyInUse(PomiceException):
pass pass
class SpotifyAlbumLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load an album."""
pass
class SpotifyTrackLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load a track."""
pass
class SpotifyPlaylistLoadFailed(PomiceException):
"""The pomice Spotify client was unable to load a playlist."""
pass
class InvalidSpotifyClientAuthorization(PomiceException):
"""No Spotify client authorization was provided for track searching."""
pass
class QueueException(Exception): class QueueException(Exception):
"""Base Pomice queue exception.""" """Base Pomice queue exception."""
pass pass

View File

@ -21,12 +21,17 @@ class Track:
track_id: str, track_id: str,
info: dict, info: dict,
ctx: Optional[commands.Context] = None, ctx: Optional[commands.Context] = None,
spotify: bool = False,
search_type: SearchType = SearchType.ytsearch, search_type: SearchType = SearchType.ytsearch,
spotify_track = None,
): ):
self.track_id = track_id self.track_id = track_id
self.info = info self.info = info
self._search_type = search_type self.spotify = spotify
self.original: Optional[Track] = None if spotify else self
self._search_type = search_type
self.spotify_track = spotify_track
self.title = info.get("title") self.title = info.get("title")
self.author = info.get("author") self.author = info.get("author")
@ -79,21 +84,29 @@ class Playlist:
playlist_info: dict, playlist_info: dict,
tracks: list, tracks: list,
ctx: Optional[commands.Context] = None, ctx: Optional[commands.Context] = None,
spotify: bool = False,
spotify_playlist = None
): ):
self.playlist_info = playlist_info self.playlist_info = playlist_info
self.tracks_raw = tracks self.tracks_raw = tracks
self.spotify = spotify
self.name = playlist_info.get("name") self.name = playlist_info.get("name")
self.spotify_playlist = spotify_playlist
self._thumbnail = None self._thumbnail = None
self._uri = None self._uri = None
if self.spotify:
self.tracks = [ self.tracks = tracks
Track(track_id=track["track"], info=track["info"], ctx=ctx) self._thumbnail = self.spotify_playlist.image
for track in self.tracks_raw self._uri = self.spotify_playlist.uri
] else:
self._thumbnail = None self.tracks = [
self._uri = None Track(track_id=track["track"], info=track["info"], ctx=ctx)
for track in self.tracks_raw
]
self._thumbnail = None
self._uri = None
if (index := playlist_info.get("selectedTrack")) == -1: if (index := playlist_info.get("selectedTrack")) == -1:
self.selected_track = None self.selected_track = None

View File

@ -17,7 +17,7 @@ from discord.ext import commands
from . import events from . import events
from .enums import SearchType from .enums import SearchType
from .events import PomiceEvent, TrackEndEvent, TrackStartEvent from .events import PomiceEvent, TrackEndEvent, TrackStartEvent
from .exceptions import FilterInvalidArgument, FilterTagAlreadyInUse, FilterTagInvalid, TrackInvalidPosition from .exceptions import FilterInvalidArgument, FilterTagAlreadyInUse, FilterTagInvalid, TrackInvalidPosition, TrackLoadError
from .filters import Filter from .filters import Filter
from .objects import Track from .objects import Track
from .pool import Node, NodePool from .pool import Node, NodePool
@ -290,15 +290,44 @@ class Player(VoiceProtocol):
end: int = 0, end: int = 0,
ignore_if_playing: bool = False ignore_if_playing: bool = False
) -> Track: ) -> Track:
"""Plays a track.""" """Plays a track. If a Spotify track is passed in, it will be handled accordingly."""
# Make sure we've never searched the track before
data = { if track.original is None:
"op": "play", # First lets try using the tracks ISRC, every track has one (hopefully)
"guildId": str(self.guild.id), try:
"track": track.track_id, if not track.isrc:
"startTime": str(start), # We have to bare raise here because theres no other way to skip this block feasibly
"noReplace": ignore_if_playing raise
} search: Track = (await self._node.get_tracks(
f"{track._search_type}:{track.isrc}", ctx=track.ctx))[0]
except Exception:
# First method didn't work, lets try just searching it up
try:
search: Track = (await self._node.get_tracks(
f"{track._search_type}:{track.title} - {track.author}", ctx=track.ctx))[0]
except:
# The song wasn't able to be found, raise error
raise TrackLoadError (
"No equivalent track was able to be found."
)
data = {
"op": "play",
"guildId": str(self.guild.id),
"track": search.track_id,
"startTime": str(start),
"noReplace": ignore_if_playing
}
track.original = search
track.track_id = search.track_id
# Set track_id for later lavalink searches
else:
data = {
"op": "play",
"guildId": str(self.guild.id),
"track": track.track_id,
"startTime": str(start),
"noReplace": ignore_if_playing
}
if end > 0: if end > 0:
data["endTime"] = str(end) data["endTime"] = str(end)

View File

@ -14,12 +14,15 @@ from discord.ext import commands
from . import ( from . import (
__version__, __version__,
spotify,
) )
from .enums import SearchType, NodeAlgorithm from .enums import SearchType, NodeAlgorithm
from .exceptions import ( from .exceptions import (
InvalidSpotifyClientAuthorization,
NodeConnectionFailure, NodeConnectionFailure,
NodeCreationError, NodeCreationError,
NodeException,
NodeNotAvailable, NodeNotAvailable,
NoNodesAvailable, NoNodesAvailable,
TrackLoadError TrackLoadError
@ -48,13 +51,14 @@ URL_REGEX = re.compile(
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.
To enable Spotify searching, pass in a proper Spotify Client ID and Spotify Client Secret
""" """
def __init__( def __init__(
self, self,
*, *,
pool, pool,
bot: commands.Bot, bot: Client,
host: str, host: str,
port: int, port: int,
password: str, password: str,
@ -62,15 +66,18 @@ class Node:
secure: bool = False, secure: bool = False,
heartbeat: int = 30, heartbeat: int = 30,
session: Optional[aiohttp.ClientSession] = None, session: Optional[aiohttp.ClientSession] = None,
spotify_client_id: Optional[str] = None,
spotify_client_secret: Optional[str] = None,
): ):
self._bot: commands.Bot = bot self._bot = bot
self._host: str = host self._host = host
self._port: int = port self._port = port
self._pool = pool self._pool = pool
self._password: str = password self._password = password
self._identifier: str = identifier self._identifier = identifier
self._heartbeat: str = heartbeat self._heartbeat = heartbeat
self._secure: bool = secure self._secure = secure
self._websocket_uri = f"{'wss' if self._secure else 'ws'}://{self._host}:{self._port}" self._websocket_uri = f"{'wss' if self._secure else 'ws'}://{self._host}:{self._port}"
@ -92,6 +99,14 @@ class Node:
self._players: Dict[int, Player] = {} self._players: Dict[int, Player] = {}
self._spotify_client_id = spotify_client_id
self._spotify_client_secret = spotify_client_secret
if self._spotify_client_id and self._spotify_client_secret:
self._spotify_client = spotify.Client(
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):
@ -118,7 +133,7 @@ class Node:
@property @property
def bot(self) -> commands.Bot: def bot(self) -> Client:
"""Property which returns the discord.py client linked to this node""" """Property which returns the discord.py client linked to this node"""
return self._bot return self._bot
@ -275,6 +290,9 @@ class Node:
): ):
"""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.
You can also pass in a discord.py Context object to get a You can also pass in a discord.py Context object to get a
Context object on any track you search. Context object on any track you search.
""" """
@ -282,8 +300,70 @@ class Node:
if not URL_REGEX.match(query) and not re.match(r"(?:ytm?|sc)search:.", query): if not URL_REGEX.match(query) and not re.match(r"(?:ytm?|sc)search:.", query):
query = f"{search_type}:{query}" query = f"{search_type}:{query}"
if SPOTIFY_URL_REGEX.match(query):
if not self._spotify_client_id and not self._spotify_client_secret:
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/"
)
if discord_url := DISCORD_MP3_URL_REGEX.match(query): spotify_results = await self._spotify_client.search(query=query)
if isinstance(spotify_results, spotify.Track):
return [
Track(
track_id=spotify_results.id,
ctx=ctx,
search_type=search_type,
spotify=True,
spotify_track=spotify_results,
info={
"title": spotify_results.name,
"author": spotify_results.artists,
"length": spotify_results.length,
"identifier": spotify_results.id,
"uri": spotify_results.uri,
"isStream": False,
"isSeekable": True,
"position": 0,
"thumbnail": spotify_results.image,
"isrc": spotify_results.isrc
}
)
]
tracks = [
Track(
track_id=track.id,
ctx=ctx,
search_type=search_type,
spotify=True,
spotify_track=track,
info={
"title": track.name,
"author": track.artists,
"length": track.length,
"identifier": track.id,
"uri": track.uri,
"isStream": False,
"isSeekable": True,
"position": 0,
"thumbnail": track.image,
"isrc": track.isrc
}
) for track in spotify_results.tracks
]
return Playlist(
playlist_info={"name": spotify_results.name, "selectedTrack": 0},
tracks=tracks,
ctx=ctx,
spotify=True,
spotify_playlist=spotify_results
)
elif discord_url := DISCORD_MP3_URL_REGEX.match(query):
async with self._session.get( async with self._session.get(
url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}",
headers={"Authorization": self._password} headers={"Authorization": self._password}
@ -428,6 +508,8 @@ class NodePool:
identifier: str, identifier: str,
secure: bool = False, secure: bool = False,
heartbeat: int = 30, heartbeat: int = 30,
spotify_client_id: Optional[str] = None,
spotify_client_secret: Optional[str] = None,
session: Optional[aiohttp.ClientSession] = None, session: Optional[aiohttp.ClientSession] = None,
) -> Node: ) -> Node:
@ -440,7 +522,8 @@ class NodePool:
node = Node( node = Node(
pool=cls, bot=bot, host=host, port=port, password=password, pool=cls, bot=bot, host=host, port=port, password=password,
identifier=identifier, secure=secure, heartbeat=heartbeat, identifier=identifier, secure=secure, heartbeat=heartbeat,
session=session spotify_client_id=spotify_client_id,
session=session, spotify_client_secret=spotify_client_secret
) )
await node.connect() await node.connect()

View File

@ -0,0 +1,5 @@
"""Spotify module for Pomice, made possible by cloudwithax 2021"""
from .exceptions import *
from .objects import *
from .client import Client

113
pomice/spotify/client.py Normal file
View File

@ -0,0 +1,113 @@
import re
import time
from base64 import b64encode
import aiohttp
import orjson as json
from .exceptions import InvalidSpotifyURL, SpotifyRequestException
from .objects import *
GRANT_URL = "https://accounts.spotify.com/api/token"
REQUEST_URL = "https://api.spotify.com/v1/{type}s/{id}"
SPOTIFY_URL_REGEX = re.compile(
r"https?://open.spotify.com/(?P<type>album|playlist|track|artist)/(?P<id>[a-zA-Z0-9]+)"
)
class Client:
"""The base client for the Spotify module of Pomice.
This class will do all the heavy lifting of getting all the metadata
for any Spotify URL you throw at it.
"""
def __init__(self, client_id: str, client_secret: str) -> None:
self._client_id = client_id
self._client_secret = client_secret
self.session = aiohttp.ClientSession()
self._bearer_token: str = None
self._expiry = 0
self._auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode())
self._grant_headers = {"Authorization": f"Basic {self._auth_token.decode()}"}
self._bearer_headers = None
async def _fetch_bearer_token(self) -> None:
_data = {"grant_type": "client_credentials"}
async with self.session.post(GRANT_URL, data=_data, headers=self._grant_headers) as resp:
if resp.status != 200:
raise SpotifyRequestException(
f"Error fetching bearer token: {resp.status} {resp.reason}"
)
data: dict = await resp.json(loads=json.loads)
self._bearer_token = data["access_token"]
self._expiry = time.time() + (int(data["expires_in"]) - 10)
self._bearer_headers = {"Authorization": f"Bearer {self._bearer_token}"}
async def search(self, *, query: str):
if not self._bearer_token or time.time() >= self._expiry:
await self._fetch_bearer_token()
result = SPOTIFY_URL_REGEX.match(query)
spotify_type = result.group("type")
spotify_id = result.group("id")
if not result:
raise InvalidSpotifyURL("The Spotify link provided is not valid.")
request_url = REQUEST_URL.format(type=spotify_type, id=spotify_id)
async with self.session.get(request_url, headers=self._bearer_headers) as resp:
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}"
)
data: dict = await resp.json(loads=json.loads)
if spotify_type == "track":
return Track(data)
elif spotify_type == "album":
return Album(data)
elif spotify_type == "artist":
async with self.session.get(f"{request_url}/top-tracks?market=US", headers=self._bearer_headers) as resp:
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}"
)
track_data: dict = await resp.json(loads=json.loads)
tracks = track_data['tracks']
return Artist(data, tracks)
else:
tracks = [
Track(track["track"])
for track in data["tracks"]["items"] if track["track"] is not None
]
if not len(tracks):
raise SpotifyRequestException("This playlist is empty and therefore cannot be queued.")
next_page_url = data["tracks"]["next"]
while next_page_url is not None:
async with self.session.get(next_page_url, headers=self._bearer_headers) as resp:
if resp.status != 200:
raise SpotifyRequestException(
f"Error while fetching results: {resp.status} {resp.reason}"
)
next_data: dict = await resp.json(loads=json.loads)
tracks += [
Track(track["track"])
for track in next_data["items"] if track["track"] is not None
]
next_page_url = next_data["next"]
return Playlist(data, tracks)

View File

@ -0,0 +1,8 @@
class SpotifyRequestException(Exception):
"""An error occurred when making a request to the Spotify API"""
pass
class InvalidSpotifyURL(Exception):
"""An invalid Spotify URL was passed"""
pass

89
pomice/spotify/objects.py Normal file
View File

@ -0,0 +1,89 @@
from typing import List
class Track:
"""The base class for a Spotify Track"""
def __init__(self, data: dict, image = None) -> None:
self.name = data["name"]
self.artists = ", ".join(artist["name"] for artist in data["artists"])
self.length = data["duration_ms"]
self.id = data["id"]
if data.get("external_ids"):
self.isrc = data["external_ids"]["isrc"]
else:
self.isrc = None
if data.get("album") and data["album"].get("images"):
self.image = data["album"]["images"][0]["url"]
else:
self.image = image
if data["is_local"]:
self.uri = None
else:
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Track name={self.name} artists={self.artists} "
f"length={self.length} id={self.id} isrc={self.isrc}>"
)
class Playlist:
"""The base class for a Spotify playlist"""
def __init__(self, data: dict, tracks: List[Track]) -> None:
self.name = data["name"]
self.tracks = tracks
self.owner = data["owner"]["display_name"]
self.total_tracks = data["tracks"]["total"]
self.id = data["id"]
if data.get("images") and len(data["images"]):
self.image = data["images"][0]["url"]
else:
self.image = None
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Playlist name={self.name} owner={self.owner} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Album:
"""The base class for a Spotify album"""
def __init__(self, data: dict) -> None:
self.name = data["name"]
self.artists = ", ".join(artist["name"] for artist in data["artists"])
self.image = data["images"][0]["url"]
self.tracks = [Track(track, image=self.image) for track in data["tracks"]["items"]]
self.total_tracks = data["total_tracks"]
self.id = data["id"]
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Album name={self.name} artists={self.artists} id={self.id} "
f"total_tracks={self.total_tracks} tracks={self.tracks}>"
)
class Artist:
"""The base class for a Spotify artist"""
def __init__(self, data: dict, tracks: dict) -> None:
self.name = f"Top tracks for {data['name']}" # Setting that because its only playing top tracks
self.genres = ", ".join(genre for genre in data["genres"])
self.followers = data["followers"]["total"]
self.image = data["images"][0]["url"]
self.tracks = [Track(track, image=self.image) for track in tracks]
self.id = data["id"]
self.uri = data["external_urls"]["spotify"]
def __repr__(self) -> str:
return (
f"<Pomice.spotify.Artist name={self.name} id={self.id} "
f"tracks={self.tracks}>"
)