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'"
)
__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 *

View File

@ -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

View File

@ -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._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.author = info.get("author")
@ -79,15 +84,23 @@ 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
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

View File

@ -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,8 +290,37 @@ class Player(VoiceProtocol):
end: int = 0,
ignore_if_playing: bool = False
) -> 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
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),

View File

@ -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:
@ -440,7 +522,8 @@ class NodePool:
node = Node(
pool=cls, bot=bot, host=host, port=port, password=password,
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()

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}>"
)