From 3687f9b33ac187f74c69c4839696b6add6eff82f Mon Sep 17 00:00:00 2001 From: cloudwithax Date: Thu, 6 Oct 2022 19:31:38 -0400 Subject: [PATCH] Revert "Remove all Spotify client code in preparation for 1.1.8" This reverts commit ab708a1cfb0f05f5a5a1a80432493b98ac1b4c19. --- pomice/__init__.py | 6 +- pomice/exceptions.py | 19 ++++++ pomice/objects.py | 29 ++++++--- pomice/player.py | 49 +++++++++++---- pomice/pool.py | 107 +++++++++++++++++++++++++++++---- pomice/spotify/__init__.py | 5 ++ pomice/spotify/client.py | 113 +++++++++++++++++++++++++++++++++++ pomice/spotify/exceptions.py | 8 +++ pomice/spotify/objects.py | 89 +++++++++++++++++++++++++++ 9 files changed, 392 insertions(+), 33 deletions(-) create mode 100644 pomice/spotify/__init__.py create mode 100644 pomice/spotify/client.py create mode 100644 pomice/spotify/exceptions.py create mode 100644 pomice/spotify/objects.py diff --git a/pomice/__init__.py b/pomice/__init__.py index be90b49..b50a9f1 100644 --- a/pomice/__init__.py +++ b/pomice/__init__.py @@ -18,15 +18,15 @@ if not discord.version_info.major >= 2: "using 'pip install discord.py'" ) -__version__ = "1.1.8a" +__version__ = "1.1.7" __title__ = "pomice" __author__ = "cloudwithax" -from .enums import * +from .enums import SearchType from .events import * from .exceptions import * from .filters import * from .objects import * -from .player import * +from .player import Player from .pool import * from .queue import * diff --git a/pomice/exceptions.py b/pomice/exceptions.py index 03536af..f244191 100644 --- a/pomice/exceptions.py +++ b/pomice/exceptions.py @@ -52,6 +52,25 @@ class FilterTagAlreadyInUse(PomiceException): 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): """Base Pomice queue exception.""" pass diff --git a/pomice/objects.py b/pomice/objects.py index c1abd66..5bfca07 100644 --- a/pomice/objects.py +++ b/pomice/objects.py @@ -21,12 +21,17 @@ class Track: track_id: str, info: dict, ctx: Optional[commands.Context] = None, + spotify: bool = False, search_type: SearchType = SearchType.ytsearch, + spotify_track = None, ): self.track_id = track_id self.info = info + 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.author = info.get("author") @@ -79,21 +84,29 @@ class Playlist: playlist_info: dict, tracks: list, ctx: Optional[commands.Context] = None, + spotify: bool = False, + spotify_playlist = None ): self.playlist_info = playlist_info self.tracks_raw = tracks + self.spotify = spotify self.name = playlist_info.get("name") + self.spotify_playlist = spotify_playlist self._thumbnail = None self._uri = None - - self.tracks = [ - Track(track_id=track["track"], info=track["info"], ctx=ctx) - for track in self.tracks_raw - ] - self._thumbnail = None - self._uri = None + if self.spotify: + self.tracks = tracks + self._thumbnail = self.spotify_playlist.image + self._uri = self.spotify_playlist.uri + else: + self.tracks = [ + 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: self.selected_track = None diff --git a/pomice/player.py b/pomice/player.py index 8006885..e6f64d3 100644 --- a/pomice/player.py +++ b/pomice/player.py @@ -17,7 +17,7 @@ from discord.ext import commands from . import events from .enums import SearchType 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 .objects import Track from .pool import Node, NodePool @@ -290,15 +290,44 @@ class Player(VoiceProtocol): end: int = 0, ignore_if_playing: bool = False ) -> Track: - """Plays a track.""" - - data = { - "op": "play", - "guildId": str(self.guild.id), - "track": track.track_id, - "startTime": str(start), - "noReplace": ignore_if_playing - } + """Plays a track. If a Spotify track is passed in, it will be handled accordingly.""" + # Make sure we've never searched the track before + if track.original is None: + # First lets try using the tracks ISRC, every track has one (hopefully) + try: + if not track.isrc: + # We have to bare raise here because theres no other way to skip this block feasibly + 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: data["endTime"] = str(end) diff --git a/pomice/pool.py b/pomice/pool.py index f412b55..5e234ff 100644 --- a/pomice/pool.py +++ b/pomice/pool.py @@ -14,12 +14,15 @@ from discord.ext import commands from . import ( __version__, + spotify, ) from .enums import SearchType, NodeAlgorithm from .exceptions import ( + InvalidSpotifyClientAuthorization, NodeConnectionFailure, NodeCreationError, + NodeException, NodeNotAvailable, NoNodesAvailable, TrackLoadError @@ -48,13 +51,14 @@ URL_REGEX = re.compile( class Node: """The base class for a 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__( self, *, pool, - bot: commands.Bot, + bot: Client, host: str, port: int, password: str, @@ -62,15 +66,18 @@ class Node: secure: bool = False, heartbeat: int = 30, session: Optional[aiohttp.ClientSession] = None, + spotify_client_id: Optional[str] = None, + spotify_client_secret: Optional[str] = None, + ): - self._bot: commands.Bot = bot - self._host: str = host - self._port: int = port + self._bot = bot + self._host = host + self._port = port self._pool = pool - self._password: str = password - self._identifier: str = identifier - self._heartbeat: str = heartbeat - self._secure: bool = secure + self._password = password + self._identifier = identifier + self._heartbeat = heartbeat + self._secure = secure 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._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") def __repr__(self): @@ -118,7 +133,7 @@ class Node: @property - def bot(self) -> commands.Bot: + def bot(self) -> Client: """Property which returns the discord.py client linked to this node""" return self._bot @@ -275,6 +290,9 @@ class Node: ): """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. """ @@ -282,8 +300,70 @@ class Node: if not URL_REGEX.match(query) and not re.match(r"(?:ytm?|sc)search:.", 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( url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", headers={"Authorization": self._password} @@ -428,6 +508,8 @@ class NodePool: identifier: str, secure: bool = False, heartbeat: int = 30, + spotify_client_id: Optional[str] = None, + spotify_client_secret: Optional[str] = None, session: Optional[aiohttp.ClientSession] = None, ) -> Node: @@ -439,8 +521,9 @@ class NodePool: node = Node( pool=cls, bot=bot, host=host, port=port, password=password, - identifier=identifier, secure=secure, heartbeat=heartbeat, - session=session + identifier=identifier, secure=secure, heartbeat=heartbeat, + spotify_client_id=spotify_client_id, + session=session, spotify_client_secret=spotify_client_secret ) await node.connect() diff --git a/pomice/spotify/__init__.py b/pomice/spotify/__init__.py new file mode 100644 index 0000000..3f012c5 --- /dev/null +++ b/pomice/spotify/__init__.py @@ -0,0 +1,5 @@ +"""Spotify module for Pomice, made possible by cloudwithax 2021""" + +from .exceptions import * +from .objects import * +from .client import Client diff --git a/pomice/spotify/client.py b/pomice/spotify/client.py new file mode 100644 index 0000000..9b4ecae --- /dev/null +++ b/pomice/spotify/client.py @@ -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/(?Palbum|playlist|track|artist)/(?P[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) \ No newline at end of file diff --git a/pomice/spotify/exceptions.py b/pomice/spotify/exceptions.py new file mode 100644 index 0000000..e421fbf --- /dev/null +++ b/pomice/spotify/exceptions.py @@ -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 diff --git a/pomice/spotify/objects.py b/pomice/spotify/objects.py new file mode 100644 index 0000000..b52b4d5 --- /dev/null +++ b/pomice/spotify/objects.py @@ -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"" + ) + +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"" + ) + +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"" + ) + +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"" + ) \ No newline at end of file