Added spotify track queueing support
This commit is contained in:
parent
34dcc1ec10
commit
eb7c529c14
|
|
@ -42,3 +42,15 @@ class TrackLoadError(PomiceException):
|
||||||
class FilterInvalidArgument(PomiceException):
|
class FilterInvalidArgument(PomiceException):
|
||||||
"""An invalid argument was passed to a filter."""
|
"""An invalid argument was passed to a filter."""
|
||||||
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
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from os import strerror
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import discord
|
import discord
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -5,18 +6,23 @@ import typing
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from . import spotify
|
||||||
from . import events
|
from . import events
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
from . import objects
|
from . import objects
|
||||||
from . import __version__
|
from . import __version__
|
||||||
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]+)')
|
||||||
|
|
||||||
class Node:
|
class Node:
|
||||||
def __init__(self, pool, bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: int, password: str, identifier: str, **kwargs):
|
def __init__(self, pool, bot: Union[commands.Bot, discord.Client, commands.AutoShardedBot, discord.AutoShardedClient], host: str, port: int, password: str, identifier: str, spotify_client_id: Optional[str], spotify_client_secret: Optional[str]):
|
||||||
self._bot = bot
|
self._bot = bot
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
|
|
@ -43,6 +49,13 @@ class Node:
|
||||||
|
|
||||||
self._players = {}
|
self._players = {}
|
||||||
|
|
||||||
|
self._spotify_client_id: str = spotify_client_id
|
||||||
|
self._spotify_client_secret: str = 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_http_client: spotify.HTTPClient = 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):
|
||||||
|
|
@ -178,6 +191,59 @@ class Node:
|
||||||
|
|
||||||
async def get_tracks(self, query: str, ctx: commands.Context = None):
|
async def get_tracks(self, query: str, ctx: commands.Context = None):
|
||||||
|
|
||||||
|
if spotify_url_check := SPOTIFY_URL_REGEX.match(query):
|
||||||
|
|
||||||
|
search_type = spotify_url_check.group('type')
|
||||||
|
spotify_id = spotify_url_check.group('id')
|
||||||
|
if search_type == "playlist":
|
||||||
|
results: spotify.Playlist = spotify.Playlist(client=self._spotify_client, data=await self._spotify_http_client.get_playlist(spotify_id))
|
||||||
|
try:
|
||||||
|
search_tracks = await results.get_all_tracks()
|
||||||
|
tracks = [
|
||||||
|
objects.Track(
|
||||||
|
track_id='spotify',
|
||||||
|
ctx=ctx,
|
||||||
|
info={'title': track.name or 'Unknown', '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
|
||||||
|
]
|
||||||
|
return objects.Playlist(playlist_info={"name": results.name, "selectedTrack": search_tracks[0]}, tracks=tracks, ctx=ctx)
|
||||||
|
except:
|
||||||
|
raise exceptions.SpotifyPlaylistLoadFailed(f"Unable to find results for {query}")
|
||||||
|
elif search_type == "album":
|
||||||
|
results: spotify.Album = await self._spotify_client.get_album(spotify_id=spotify_id)
|
||||||
|
try:
|
||||||
|
search_tracks = await results.get_all_tracks()
|
||||||
|
tracks = [
|
||||||
|
objects.Track(
|
||||||
|
track_id='spotify',
|
||||||
|
ctx=ctx,
|
||||||
|
info={'title': track.name or 'Unknown', '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
|
||||||
|
]
|
||||||
|
|
||||||
|
return objects.Playlist(playlist_info={"name": results.name, "selectedTrack": search_tracks[0]}, tracks=tracks, ctx=ctx)
|
||||||
|
except:
|
||||||
|
raise exceptions.SpotifyAlbumLoadFailed(f"Unable to find results for {query}")
|
||||||
|
elif search_type == 'track':
|
||||||
|
try:
|
||||||
|
results: spotify.Track = await self._spotify_client.get_track(spotify_id=spotify_id)
|
||||||
|
return objects.Track(
|
||||||
|
track_id='spotify',
|
||||||
|
ctx=ctx,
|
||||||
|
info={'title': results.name or 'Unknown', 'author': ', '.join(artist.name for artist in results.artists) 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:
|
||||||
|
raise exceptions.SpotifyTrackLoadFailed(f"Unable to find results for {query}")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
@ -193,13 +259,9 @@ class Node:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
elif load_type == "PLAYLIST_LOADED":
|
elif load_type == "PLAYLIST_LOADED":
|
||||||
if ctx:
|
|
||||||
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"], ctx=ctx)
|
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"], ctx=ctx)
|
||||||
else:
|
|
||||||
return objects.Playlist(playlist_info=data["playlistInfo"], tracks=data["tracks"])
|
|
||||||
|
|
||||||
elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED":
|
elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED":
|
||||||
if ctx:
|
|
||||||
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"]]
|
||||||
else:
|
|
||||||
return [objects.Track(track_id=track["track"], info=track["info"]) for track in data["tracks"]]
|
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,8 @@ 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 ctx:
|
|
||||||
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]
|
||||||
else:
|
|
||||||
self.tracks = [Track(track_id=track["track"], info=track["info"]) for track in self.tracks_raw]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ class Player(VoiceProtocol):
|
||||||
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):
|
||||||
|
if track.track_id == "spotify":
|
||||||
|
track: objects.Track = await self._node.get_tracks(f"{track.title} {track.author}")
|
||||||
await self._node.send(op='play', guildId=str(self._guild.id), track=track.track_id, startTime=start_position, endTime=track.length, noReplace=False)
|
await self._node.send(op='play', guildId=str(self._guild.id), track=track.track_id, startTime=start_position, endTime=track.length, noReplace=False)
|
||||||
self._current = track
|
self._current = track
|
||||||
return self._current
|
return self._current
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
__version__ = "0.10.2"
|
||||||
|
__title__ = "spotify"
|
||||||
|
__author__ = "mental"
|
||||||
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
from typing import Dict, Type
|
||||||
|
|
||||||
|
from .oauth import *
|
||||||
|
from .utils import clean as _clean_namespace
|
||||||
|
from .errors import *
|
||||||
|
from .models import *
|
||||||
|
from .client import *
|
||||||
|
from .models import SpotifyBase
|
||||||
|
from .http import HTTPClient, HTTPUserClient
|
||||||
|
|
||||||
|
__all__ = tuple(name for name in locals() if name[0] != "_")
|
||||||
|
|
||||||
|
_locals = locals() # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
_types: Dict[str, Type[Union[SpotifyBase, HTTPClient]]]
|
||||||
|
with _clean_namespace(locals(), "_locals", "_clean_namespace"):
|
||||||
|
_types = dict( # pylint: disable=invalid-name
|
||||||
|
(name, _locals[name])
|
||||||
|
for name, obj in _locals.items()
|
||||||
|
if isinstance(obj, type) and issubclass(obj, SpotifyBase)
|
||||||
|
)
|
||||||
|
_types["HTTPClient"] = HTTPClient
|
||||||
|
_types["HTTPUserClient"] = HTTPUserClient
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List, Iterable, NamedTuple, Type, Union, Dict
|
||||||
|
|
||||||
|
from .http import HTTPClient
|
||||||
|
from .utils import to_id
|
||||||
|
from . import OAuth2, Artist, Album, Track, User, Playlist
|
||||||
|
|
||||||
|
__all__ = ("Client", "SearchResults")
|
||||||
|
|
||||||
|
_TYPES = {"artist": Artist, "album": Album, "playlist": Playlist, "track": Track}
|
||||||
|
|
||||||
|
_SEARCH_TYPES = {"track", "playlist", "artist", "album"}
|
||||||
|
_SEARCH_TYPE_ERR = (
|
||||||
|
'Bad queary type! got "%s" expected any of: track, playlist, artist, album'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResults(NamedTuple):
|
||||||
|
"""A namedtuple of search results.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
artists : List[:class:`Artist`]
|
||||||
|
The artists of the search.
|
||||||
|
playlists : List[:class:`Playlist`]
|
||||||
|
The playlists of the search.
|
||||||
|
albums : List[:class:`Album`]
|
||||||
|
The albums of the search.
|
||||||
|
tracks : List[:class:`Track`]
|
||||||
|
The tracks of the search.
|
||||||
|
"""
|
||||||
|
|
||||||
|
artists: Optional[List[Artist]] = None
|
||||||
|
playlists: Optional[List[Playlist]] = None
|
||||||
|
albums: Optional[List[Album]] = None
|
||||||
|
tracks: Optional[List[Track]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
"""Represents a Client app on Spotify.
|
||||||
|
|
||||||
|
This class is used to interact with the Spotify API.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The client id provided by spotify for the app.
|
||||||
|
client_secret : :class:`str`
|
||||||
|
The client secret for the app.
|
||||||
|
loop : Optional[:class:`asyncio.AbstractEventLoop`]
|
||||||
|
The event loop the client should run on, if no loop is specified `asyncio.get_event_loop()` is called and used instead.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The applications client_id, also aliased as `id`
|
||||||
|
http : :class:`HTTPClient`
|
||||||
|
The HTTPClient that is being used.
|
||||||
|
loop : Optional[:class:`asyncio.AbstractEventLoop`]
|
||||||
|
The event loop the client is running on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_default_http_client: Type[HTTPClient] = HTTPClient
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
*,
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(client_id, str):
|
||||||
|
raise TypeError("client_id must be a string.")
|
||||||
|
|
||||||
|
if not isinstance(client_secret, str):
|
||||||
|
raise TypeError("client_secret must be a string.")
|
||||||
|
|
||||||
|
if loop is not None and not isinstance(loop, asyncio.AbstractEventLoop):
|
||||||
|
raise TypeError(
|
||||||
|
"loop argument must be None or an instance of asyncio.AbstractEventLoop."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.loop = loop = loop or asyncio.get_event_loop()
|
||||||
|
self.http = self._default_http_client(client_id, client_secret, loop=loop)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Client: {self.http.client_id!r}>"
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "Client":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_id(self) -> str:
|
||||||
|
""":class:`str` - The Spotify client ID."""
|
||||||
|
return self.http.client_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self): # pylint: disable=invalid-name
|
||||||
|
""":class:`str` - The Spotify client ID."""
|
||||||
|
return self.http.client_id
|
||||||
|
|
||||||
|
# Public api
|
||||||
|
|
||||||
|
def oauth2_url(
|
||||||
|
self,
|
||||||
|
redirect_uri: str,
|
||||||
|
scopes: Optional[Union[Iterable[str], Dict[str, bool]]] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an oauth2 url for user authentication.
|
||||||
|
|
||||||
|
This is an alias to :meth:`OAuth2.url_only` but the
|
||||||
|
difference is that the client id is autmatically
|
||||||
|
passed in to the constructor.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
redirect_uri : :class:`str`
|
||||||
|
Where spotify should redirect the user to after authentication.
|
||||||
|
scopes : Optional[Iterable[:class:`str`], Dict[:class:`str`, :class:`bool`]]
|
||||||
|
The scopes to be requested.
|
||||||
|
state : Optional[:class:`str`]
|
||||||
|
Using a state value can increase your assurance that an incoming connection is the result of an
|
||||||
|
authentication request.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
url : :class:`str`
|
||||||
|
The OAuth2 url.
|
||||||
|
"""
|
||||||
|
return OAuth2.url_only(
|
||||||
|
client_id=self.http.client_id,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scopes=scopes,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP session to Spotify."""
|
||||||
|
await self.http.close()
|
||||||
|
|
||||||
|
async def user_from_token(self, token: str) -> User:
|
||||||
|
"""Create a user session from a token.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This code is equivelent to `User.from_token(client, token)`
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token : :class:`str`
|
||||||
|
The token to attatch the user session to.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
user : :class:`spotify.User`
|
||||||
|
The user from the ID
|
||||||
|
"""
|
||||||
|
return await User.from_token(self, token)
|
||||||
|
|
||||||
|
async def get_album(self, spotify_id: str, *, market: str = "US") -> Album:
|
||||||
|
"""Retrive an album with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : :class:`str`
|
||||||
|
The ID to search for.
|
||||||
|
market : Optional[:class:`str`]
|
||||||
|
An ISO 3166-1 alpha-2 country code
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
album : :class:`spotify.Album`
|
||||||
|
The album from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.album(to_id(spotify_id), market=market)
|
||||||
|
return Album(self, data)
|
||||||
|
|
||||||
|
async def get_artist(self, spotify_id: str) -> Artist:
|
||||||
|
"""Retrive an artist with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
artist : Artist
|
||||||
|
The artist from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.artist(to_id(spotify_id))
|
||||||
|
return Artist(self, data)
|
||||||
|
|
||||||
|
async def get_track(self, spotify_id: str) -> Track:
|
||||||
|
"""Retrive an track with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
track : Track
|
||||||
|
The track from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.track(to_id(spotify_id))
|
||||||
|
return Track(self, data)
|
||||||
|
|
||||||
|
async def get_user(self, spotify_id: str) -> User:
|
||||||
|
"""Retrive an user with a spotify ID.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
spotify_id : str
|
||||||
|
The ID to search for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
user : User
|
||||||
|
The user from the ID
|
||||||
|
"""
|
||||||
|
data = await self.http.user(to_id(spotify_id))
|
||||||
|
return User(self, data)
|
||||||
|
|
||||||
|
# Get multiple objects
|
||||||
|
|
||||||
|
async def get_albums(self, *ids: str, market: str = "US") -> List[Album]:
|
||||||
|
"""Retrive multiple albums with a list of spotify IDs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ids : List[str]
|
||||||
|
the ID to look for
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums from the IDs
|
||||||
|
"""
|
||||||
|
data = await self.http.albums(
|
||||||
|
",".join(to_id(_id) for _id in ids), market=market
|
||||||
|
)
|
||||||
|
return list(Album(self, album) for album in data["albums"])
|
||||||
|
|
||||||
|
async def get_artists(self, *ids: str) -> List[Artist]:
|
||||||
|
"""Retrive multiple artists with a list of spotify IDs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ids : List[:class:`str`]
|
||||||
|
The IDs to look for.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
artists : List[:class:`Artist`]
|
||||||
|
The artists from the IDs
|
||||||
|
"""
|
||||||
|
data = await self.http.artists(",".join(to_id(_id) for _id in ids))
|
||||||
|
return list(Artist(self, artist) for artist in data["artists"])
|
||||||
|
|
||||||
|
async def search( # pylint: disable=invalid-name
|
||||||
|
self,
|
||||||
|
q: str,
|
||||||
|
*,
|
||||||
|
types: Iterable[str] = ("track", "playlist", "artist", "album"),
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
market: str = "US",
|
||||||
|
should_include_external: bool = False,
|
||||||
|
) -> SearchResults:
|
||||||
|
"""Access the spotify search functionality.
|
||||||
|
|
||||||
|
>>> results = client.search('Cadet', types=['artist'])
|
||||||
|
>>> for artist in result.get('artists', []):
|
||||||
|
... if artist.name.lower() == 'cadet':
|
||||||
|
... print(repr(artist))
|
||||||
|
... break
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
q : :class:`str`
|
||||||
|
the search query
|
||||||
|
types : Optional[Iterable[`:class:`str`]]
|
||||||
|
A sequence of search types (can be any of `track`, `playlist`, `artist` or `album`) to refine the search request.
|
||||||
|
A `ValueError` may be raised if a search type is found that is not valid.
|
||||||
|
limit : Optional[:class:`int`]
|
||||||
|
The limit of search results to return when searching.
|
||||||
|
Maximum limit is 50, any larger may raise a :class:`HTTPException`
|
||||||
|
offset : Optional[:class:`int`]
|
||||||
|
The offset from where the api should start from in the search results.
|
||||||
|
market : Optional[:class:`str`]
|
||||||
|
An ISO 3166-1 alpha-2 country code. Provide this parameter if you want to apply Track Relinking.
|
||||||
|
should_include_external : :class:`bool`
|
||||||
|
If `True` is specified, the response will include any relevant audio content
|
||||||
|
that is hosted externally. By default external content is filtered out from responses.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
results : :class:`SearchResults`
|
||||||
|
The results of the search.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
Raised when a parameter with a bad type is passed.
|
||||||
|
ValueError
|
||||||
|
Raised when a bad search type is passed with the `types` argument.
|
||||||
|
"""
|
||||||
|
if not hasattr(types, "__iter__"):
|
||||||
|
raise TypeError("types must be an iterable.")
|
||||||
|
|
||||||
|
types_ = set(types)
|
||||||
|
|
||||||
|
if not types_.issubset(_SEARCH_TYPES):
|
||||||
|
raise ValueError(_SEARCH_TYPE_ERR % types_.difference(_SEARCH_TYPES).pop())
|
||||||
|
|
||||||
|
query_type = ",".join(tp.strip() for tp in types)
|
||||||
|
|
||||||
|
include_external: Optional[str]
|
||||||
|
if should_include_external:
|
||||||
|
include_external = "audio"
|
||||||
|
else:
|
||||||
|
include_external = None
|
||||||
|
|
||||||
|
data = await self.http.search(
|
||||||
|
q=q,
|
||||||
|
query_type=query_type,
|
||||||
|
market=market,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_external=include_external,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResults(
|
||||||
|
**{
|
||||||
|
key: [_TYPES[obj["type"]](self, obj) for obj in value["items"]]
|
||||||
|
for key, value in data.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
__all__ = ("SpotifyException", "HTTPException", "Forbidden", "NotFound")
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyException(Exception):
|
||||||
|
"""Base exception class for spotify.py."""
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPException(SpotifyException):
|
||||||
|
"""A generic exception that's thrown when a HTTP operation fails."""
|
||||||
|
|
||||||
|
def __init__(self, response, message):
|
||||||
|
self.response = response
|
||||||
|
self.status = response.status
|
||||||
|
error = message.get("error")
|
||||||
|
|
||||||
|
if isinstance(error, dict):
|
||||||
|
self.text = error.get("message", "")
|
||||||
|
else:
|
||||||
|
self.text = message.get("error_description", "")
|
||||||
|
|
||||||
|
fmt = "{0.reason} (status code: {0.status})"
|
||||||
|
if self.text.strip():
|
||||||
|
fmt += ": {1}"
|
||||||
|
|
||||||
|
super().__init__(fmt.format(self.response, self.text))
|
||||||
|
|
||||||
|
|
||||||
|
class Forbidden(HTTPException):
|
||||||
|
"""An exception that's thrown when status code 403 occurs."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(HTTPException):
|
||||||
|
"""An exception that's thrown when status code 404 occurs."""
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenError(HTTPException):
|
||||||
|
"""An exception that's thrown when Spotify could not provide a valid Bearer Token"""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitedException(Exception):
|
||||||
|
"""An exception that gets thrown when a rate limit is encountered."""
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
||||||
|
from .. import _clean_namespace
|
||||||
|
from . import typing
|
||||||
|
|
||||||
|
from .base import AsyncIterable, SpotifyBase, URIBase
|
||||||
|
from .common import Device, Context, Image
|
||||||
|
from .artist import Artist
|
||||||
|
from .track import Track, PlaylistTrack
|
||||||
|
from .player import Player
|
||||||
|
from .album import Album
|
||||||
|
from .library import Library
|
||||||
|
from .playlist import Playlist
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"User",
|
||||||
|
"Track",
|
||||||
|
"PlaylistTrack",
|
||||||
|
"Artist",
|
||||||
|
"Album",
|
||||||
|
"Playlist",
|
||||||
|
"Library",
|
||||||
|
"Player",
|
||||||
|
"Device",
|
||||||
|
"Context",
|
||||||
|
"Image",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import AsyncIterable, URIBase, Image, Artist, Track
|
||||||
|
|
||||||
|
|
||||||
|
class Album(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Album.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
artists : List[Artist]
|
||||||
|
The artists for the album.
|
||||||
|
id : str
|
||||||
|
The ID of the album.
|
||||||
|
name : str
|
||||||
|
The name of the album.
|
||||||
|
href : str
|
||||||
|
The HTTP API URL for the album.
|
||||||
|
uri : str
|
||||||
|
The URI for the album.
|
||||||
|
album_group : str
|
||||||
|
ossible values are “album”, “single”, “compilation”, “appears_on”.
|
||||||
|
Compare to album_type this field represents relationship between the artist and the album.
|
||||||
|
album_type : str
|
||||||
|
The type of the album: one of "album" , "single" , or "compilation".
|
||||||
|
release_date : str
|
||||||
|
The date the album was first released.
|
||||||
|
release_date_precision : str
|
||||||
|
The precision with which release_date value is known: year, month or day.
|
||||||
|
genres : List[str]
|
||||||
|
A list of the genres used to classify the album.
|
||||||
|
label : str
|
||||||
|
The label for the album.
|
||||||
|
popularity : int
|
||||||
|
The popularity of the album. The value will be between 0 and 100, with 100 being the most popular.
|
||||||
|
copyrights : List[Dict]
|
||||||
|
The copyright statements of the album.
|
||||||
|
markets : List[str]
|
||||||
|
The markets in which the album is available: ISO 3166-1 alpha-2 country codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
# Simple object attributes.
|
||||||
|
self.type = data.pop("album_type", None)
|
||||||
|
self.group = data.pop("album_group", None)
|
||||||
|
self.artists = [Artist(client, artist) for artist in data.pop("artists", [])]
|
||||||
|
|
||||||
|
self.artist = self.artists[0] if self.artists else None
|
||||||
|
self.markets = data.pop("avaliable_markets", None)
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.id = data.pop("id", None) # pylint: disable=invalid-name
|
||||||
|
self.name = data.pop("name", None)
|
||||||
|
self.href = data.pop("href", None)
|
||||||
|
self.uri = data.pop("uri", None)
|
||||||
|
self.release_date = data.pop("release_date", None)
|
||||||
|
self.release_date_precision = data.pop("release_date_precision", None)
|
||||||
|
self.images = [Image(**image) for image in data.pop("images", [])]
|
||||||
|
self.restrictions = data.pop("restrictions", None)
|
||||||
|
|
||||||
|
# Full object attributes
|
||||||
|
self.genres = data.pop("genres", None)
|
||||||
|
self.copyrights = data.pop("copyrights", None)
|
||||||
|
self.label = data.pop("label", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.total_tracks = data.pop("total_tracks", None)
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = Track
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.album_tracks, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Album: {(self.name or self.id or self.uri)!r}>"
|
||||||
|
|
||||||
|
# Public
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_tracks(
|
||||||
|
self, *, limit: Optional[int] = 20, offset: Optional[int] = 0
|
||||||
|
) -> List[Track]:
|
||||||
|
"""get the albums tracks from spotify.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many tracks to retrieve for this album (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
data = await self.__client.http.album_tracks(
|
||||||
|
self.id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return list(Track(self.__client, item, album=self) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_tracks(
|
||||||
|
self, *, market: Optional[str] = "US"
|
||||||
|
) -> List[Track]: # pylint: disable=unused-argument
|
||||||
|
"""loads all of the albums tracks, depending on how many the album has this may be a long operation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code. Provide this parameter if you want to apply Track Relinking.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[:class:`spotify.Track`]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
return [track async for track in self]
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional, List, TYPE_CHECKING
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import AsyncIterable, URIBase, Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class Artist(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Artist.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : str
|
||||||
|
The Spotify ID of the artist.
|
||||||
|
uri : str
|
||||||
|
The URI of the artist.
|
||||||
|
url : str
|
||||||
|
The open.spotify URL.
|
||||||
|
href : str
|
||||||
|
A link to the Web API endpoint providing full details of the artist.
|
||||||
|
name : str
|
||||||
|
The name of the artist.
|
||||||
|
genres : List[str]
|
||||||
|
A list of the genres the artist is associated with.
|
||||||
|
For example: "Prog Rock" , "Post-Grunge". (If not yet classified, the array is empty.)
|
||||||
|
followers : Optional[int]
|
||||||
|
The total number of followers.
|
||||||
|
popularity : int
|
||||||
|
The popularity of the artist.
|
||||||
|
The value will be between 0 and 100, with 100 being the most popular.
|
||||||
|
The artist’s popularity is calculated from the popularity of all the artist’s tracks.
|
||||||
|
images : List[Image]
|
||||||
|
Images of the artist in various sizes, widest first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
# Simplified object attributes
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.name = data.pop("name")
|
||||||
|
|
||||||
|
# Full object attributes
|
||||||
|
self.genres = data.pop("genres", None)
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.images = list(Image(**image) for image in data.pop("images", []))
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
self.__aiter_klass__ = Album
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.artist_albums, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Artist: {self.name!r}>"
|
||||||
|
|
||||||
|
# Public
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_albums(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: Optional[int] = 20,
|
||||||
|
offset: Optional[int] = 0,
|
||||||
|
include_groups=None,
|
||||||
|
market: Optional[str] = None,
|
||||||
|
) -> List["spotify.Album"]:
|
||||||
|
"""Get the albums of a Spotify artist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optiona[int]
|
||||||
|
The offset of which Spotify should start yielding from.
|
||||||
|
include_groups : INCLUDE_GROUPS_TP
|
||||||
|
INCLUDE_GROUPS
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums of the artist.
|
||||||
|
"""
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_groups=include_groups,
|
||||||
|
market=market,
|
||||||
|
)
|
||||||
|
return list(Album(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_albums(self, *, market="US") -> List["spotify.Album"]:
|
||||||
|
"""loads all of the artists albums, depending on how many the artist has this may be a long operation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[Album]
|
||||||
|
The albums of the artist.
|
||||||
|
"""
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
albums: List[Album] = []
|
||||||
|
offset = 0
|
||||||
|
total = await self.total_albums(market=market)
|
||||||
|
|
||||||
|
while len(albums) < total:
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id, limit=50, offset=offset, market=market
|
||||||
|
)
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
albums += list(Album(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
return albums
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def total_albums(self, *, market: str = None) -> int:
|
||||||
|
"""get the total amout of tracks in the album.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
market : Optional[str]
|
||||||
|
An ISO 3166-1 alpha-2 country code.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
total : int
|
||||||
|
The total amount of albums.
|
||||||
|
"""
|
||||||
|
data = await self.__client.http.artist_albums(
|
||||||
|
self.id, limit=1, offset=0, market=market
|
||||||
|
)
|
||||||
|
return data["total"]
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def top_tracks(self, country: str = "US") -> List["spotify.Track"]:
|
||||||
|
"""Get Spotify catalog information about an artist’s top tracks by country.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
country : str
|
||||||
|
The country to search for, it defaults to 'US'.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The artists top tracks.
|
||||||
|
"""
|
||||||
|
from .track import Track
|
||||||
|
|
||||||
|
top = await self.__client.http.artist_top_tracks(self.id, country=country)
|
||||||
|
return list(Track(self.__client, item) for item in top["tracks"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def related_artists(self) -> List["Artist"]:
|
||||||
|
"""Get Spotify catalog information about artists similar to a given artist.
|
||||||
|
|
||||||
|
Similarity is based on analysis of the Spotify community’s listening history.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
artists : List[Artist]
|
||||||
|
The artists deemed similar.
|
||||||
|
"""
|
||||||
|
related = await self.__client.http.artist_related_artists(self.id)
|
||||||
|
return list(Artist(self.__client, item) for item in related["artists"])
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
from typing import Optional, Callable, Type
|
||||||
|
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyBase:
|
||||||
|
"""The base class all Spotify models **must** derive from.
|
||||||
|
|
||||||
|
This base class is used to transparently construct spotify
|
||||||
|
models based on the :class:`spotify,Client` type.
|
||||||
|
|
||||||
|
Currently it is used to detect whether a Client is a synchronous
|
||||||
|
client and, if as such, construct and return the appropriate
|
||||||
|
synchronous model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __new__(cls, client, *_, **__):
|
||||||
|
if not isinstance(client, spotify.Client):
|
||||||
|
raise TypeError(
|
||||||
|
f"{cls!r}: expected client argument to be an instance of `spotify.Client`. Instead got {type(client)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(client, "__client_thread__"):
|
||||||
|
cls = getattr( # pylint: disable=self-cls-assignment
|
||||||
|
spotify.sync.models, cls.__name__
|
||||||
|
)
|
||||||
|
|
||||||
|
return object.__new__(cls)
|
||||||
|
|
||||||
|
async def from_href(self):
|
||||||
|
"""Get the full object from spotify with a `href` attribute.
|
||||||
|
|
||||||
|
.. note ::
|
||||||
|
|
||||||
|
This can be used to get an updated model of the object.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
model : SpotifyBase
|
||||||
|
An instance of whatever the class was before.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TypeError
|
||||||
|
This is raised if the model has no `href` attribute.
|
||||||
|
|
||||||
|
Additionally if the model has no `http` attribute and
|
||||||
|
the model has no way to access its client, while theoretically
|
||||||
|
impossible its a failsafe, this will be raised.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "href"):
|
||||||
|
raise TypeError(
|
||||||
|
"Spotify object has no `href` attribute, therefore cannot be retrived"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(self, "http"):
|
||||||
|
return await self.http.request( # pylint: disable=no-member
|
||||||
|
("GET", self.href) # pylint: disable=no-member
|
||||||
|
)
|
||||||
|
|
||||||
|
klass = type(self)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = getattr(self, f"_{klass.__name__}__client")
|
||||||
|
except AttributeError:
|
||||||
|
raise TypeError("Spotify object has no way to access a HTTPClient.")
|
||||||
|
else:
|
||||||
|
http = client.http # pylint: disable=no-member
|
||||||
|
|
||||||
|
data = await http.request(("GET", self.href)) # pylint: disable=no-member
|
||||||
|
|
||||||
|
return klass(client, data)
|
||||||
|
|
||||||
|
|
||||||
|
class URIBase(SpotifyBase):
|
||||||
|
"""Base class used for inheriting magic methods for models who have URIs.
|
||||||
|
|
||||||
|
This class inherits from :class:`SpotifyBase` and is used to reduce boilerplate
|
||||||
|
in spotify models by supplying a `__eq__`, `__ne__`, and `__str__` double underscore
|
||||||
|
methods.
|
||||||
|
|
||||||
|
The objects that inherit from :class:`URIBase` support equality and string casting.
|
||||||
|
|
||||||
|
- Two objects are equal if **They are strictly the same type and have the same uri**
|
||||||
|
- Casting to a string will return the uri of the object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
uri = repr(None)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.uri) # pylint: disable=no-member
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (
|
||||||
|
type(self) is type(other) and self.uri == other.uri
|
||||||
|
) # pylint: disable=no-member
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.uri # pylint: disable=no-member
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncIterable(SpotifyBase):
|
||||||
|
"""Base class intended for all models that can be asynchronously iterated over.
|
||||||
|
|
||||||
|
This class implements two magic class vars:
|
||||||
|
|
||||||
|
* `__aiter_fetch__` ~ A coroutine function that accepts a keyword argument named `option`
|
||||||
|
* `__aiter_klass__` ~ A spotify model class, essentially a type that subclasses `SpotifyBase`
|
||||||
|
|
||||||
|
Additionally the class implements `__aiter__` that will exhaust the paging
|
||||||
|
objects returned by the `__aiter_fetch__` calls and yield each data item in
|
||||||
|
said paging objects as an instance of `__aiter_klass__`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__aiter_fetch__: Optional[Callable] = None
|
||||||
|
__aiter_klass__: Optional[Type[SpotifyBase]] = None
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
client = getattr(self, f"_{type(self).__name__}__client")
|
||||||
|
|
||||||
|
assert self.__aiter_fetch__ is not None
|
||||||
|
fetch = self.__aiter_fetch__
|
||||||
|
|
||||||
|
assert self.__aiter_klass__ is not None
|
||||||
|
klass = self.__aiter_klass__
|
||||||
|
|
||||||
|
total = None
|
||||||
|
processed = offset = 0
|
||||||
|
|
||||||
|
while total is None or processed < total:
|
||||||
|
data = await fetch(offset=offset) # pylint: disable=not-callable
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
assert "total" in data
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
assert "items" in data
|
||||||
|
for item in data["items"]:
|
||||||
|
processed += 1
|
||||||
|
yield klass(client, item) # pylint: disable=not-callable
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
class Image:
|
||||||
|
"""An object representing a Spotify image resource.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
height : :class:`str`
|
||||||
|
The height of the image.
|
||||||
|
width : :class:`str`
|
||||||
|
The width of the image.
|
||||||
|
url : :class:`str`
|
||||||
|
The URL of the image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("height", "width", "url")
|
||||||
|
|
||||||
|
def __init__(self, *, height: str, width: str, url: str):
|
||||||
|
self.height = height
|
||||||
|
self.width = width
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Image: {self.url!r} (width: {self.width!r}, height: {self.height!r})>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.url == other.url
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
"""A Spotify Context.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
type : str
|
||||||
|
The object type, e.g. “artist”, “playlist”, “album”.
|
||||||
|
href : str
|
||||||
|
A link to the Web API endpoint providing full details of the track.
|
||||||
|
external_urls : str
|
||||||
|
External URLs for this context.
|
||||||
|
uri : str
|
||||||
|
The Spotify URI for the context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("external_urls", "type", "href", "uri")
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.external_urls = data.get("external_urls")
|
||||||
|
self.type = data.get("type")
|
||||||
|
|
||||||
|
self.href = data.get("href")
|
||||||
|
self.uri = data.get("uri")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Context: {self.uri!r}>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.uri == other.uri
|
||||||
|
|
||||||
|
|
||||||
|
class Device:
|
||||||
|
"""A Spotify Users device.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : str
|
||||||
|
The device ID
|
||||||
|
name : int
|
||||||
|
The name of the device.
|
||||||
|
type : str
|
||||||
|
A Device type, such as “Computer”, “Smartphone” or “Speaker”.
|
||||||
|
volume : int
|
||||||
|
The current volume in percent. This may be null.
|
||||||
|
is_active : bool
|
||||||
|
if this device is the currently active device.
|
||||||
|
is_restricted : bool
|
||||||
|
Whether controlling this device is restricted.
|
||||||
|
At present if this is “true” then no Web API commands will be accepted by this device.
|
||||||
|
is_private_session : bool
|
||||||
|
If this device is currently in a private session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"volume",
|
||||||
|
"is_active",
|
||||||
|
"is_restricted",
|
||||||
|
"is_private_session",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.id = data.get("id") # pylint: disable=invalid-name
|
||||||
|
self.name = data.get("name")
|
||||||
|
self.type = data.get("type")
|
||||||
|
|
||||||
|
self.volume = data.get("volume_percent")
|
||||||
|
|
||||||
|
self.is_active = data.get("is_active")
|
||||||
|
self.is_restricted = data.get("is_restricted")
|
||||||
|
self.is_private_session = data.get("is_private_session")
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.id == other.id
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Device: {(self.name or self.id)!r}>"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.id
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
from typing import Sequence, Union, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import SpotifyBase
|
||||||
|
from .track import Track
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
|
||||||
|
class Library(SpotifyBase):
|
||||||
|
"""A Spotify Users Library.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
user : :class:`Spotify.User`
|
||||||
|
The user which this library object belongs to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, user):
|
||||||
|
self.user = user
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Library: {self.user!r}>"
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return type(self) is type(other) and self.user == other.user
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def contains_albums(self, *albums: Sequence[Union[str, Album]]) -> List[bool]:
|
||||||
|
"""Check if one or more albums is already saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Union[Album, str]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [str(obj) for obj in albums]
|
||||||
|
return await self.user.http.is_saved_album(_albums)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def contains_tracks(self, *tracks: Sequence[Union[str, Track]]) -> List[bool]:
|
||||||
|
"""Check if one or more tracks is already saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Union[Track, str]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [str(obj) for obj in tracks]
|
||||||
|
return await self.user.http.is_saved_track(_tracks)
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_tracks(self, *, limit=20, offset=0) -> List[Track]:
|
||||||
|
"""Get a list of the songs saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first item to return. Default: 0
|
||||||
|
"""
|
||||||
|
data = await self.user.http.saved_tracks(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
return [Track(self.__client, item["track"]) for item in data["items"]]
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_all_tracks(self) -> List[Track]:
|
||||||
|
"""Get a list of all the songs saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[:class:`Track`]
|
||||||
|
The tracks of the artist.
|
||||||
|
"""
|
||||||
|
tracks: List[Track] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.user.http.saved_tracks(limit=50, offset=offset)
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
tracks += list(
|
||||||
|
Track(self.__client, item["track"]) for item in data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(tracks) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_albums(self, *, limit=20, offset=0) -> List[Album]:
|
||||||
|
"""Get a list of the albums saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first item to return. Default: 0
|
||||||
|
"""
|
||||||
|
data = await self.user.http.saved_albums(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
return [Album(self.__client, item["album"]) for item in data["items"]]
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-read")
|
||||||
|
async def get_all_albums(self) -> List[Album]:
|
||||||
|
"""Get a list of the albums saved in the current Spotify user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
albums : List[:class:`Album`]
|
||||||
|
The albums.
|
||||||
|
"""
|
||||||
|
albums: List[Album] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.user.http.saved_albums(limit=50, offset=offset)
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
albums += list(
|
||||||
|
Album(self.__client, item["album"]) for item in data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(albums) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return albums
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def remove_albums(self, *albums):
|
||||||
|
"""Remove one or more albums from the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Sequence[Union[Album, str]]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [(obj if isinstance(obj, str) else obj.id) for obj in albums]
|
||||||
|
await self.user.http.delete_saved_albums(",".join(_albums))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def remove_tracks(self, *tracks):
|
||||||
|
"""Remove one or more tracks from the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Sequence[Union[Track, str]]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [(obj if isinstance(obj, str) else obj.id) for obj in tracks]
|
||||||
|
await self.user.http.delete_saved_tracks(",".join(_tracks))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def save_albums(self, *albums):
|
||||||
|
"""Save one or more albums to the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
albums : Sequence[Union[Album, str]]
|
||||||
|
A sequence of artist objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_albums = [(obj if isinstance(obj, str) else obj.id) for obj in albums]
|
||||||
|
await self.user.http.save_albums(",".join(_albums))
|
||||||
|
|
||||||
|
@set_required_scopes("user-library-modify")
|
||||||
|
async def save_tracks(self, *tracks):
|
||||||
|
"""Save one or more tracks to the current user’s ‘Your Music’ library.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Sequence[Union[Track, str]]
|
||||||
|
A sequence of track objects or spotify IDs
|
||||||
|
"""
|
||||||
|
_tracks = [(obj if isinstance(obj, str) else obj.id) for obj in tracks]
|
||||||
|
await self.user.http.save_tracks(_tracks)
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
from typing import Union, Optional, List
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import SpotifyBase, Device, Track
|
||||||
|
from .typing import SomeURIs, SomeURI
|
||||||
|
|
||||||
|
Offset = Union[int, str, Track]
|
||||||
|
SomeDevice = Union[Device, str]
|
||||||
|
|
||||||
|
|
||||||
|
class Player(SpotifyBase): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Users current playback.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
device : :class:`spotify.Device`
|
||||||
|
The device that is currently active.
|
||||||
|
repeat_state : :class:`str`
|
||||||
|
"off", "track", "context"
|
||||||
|
shuffle_state : :class:`bool`
|
||||||
|
If shuffle is on or off.
|
||||||
|
is_playing : :class:`bool`
|
||||||
|
If something is currently playing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, user, data):
|
||||||
|
self.__client = client
|
||||||
|
self.__user = user
|
||||||
|
|
||||||
|
self.repeat_state = data.get("repeat_state", None)
|
||||||
|
self.shuffle_state = data.pop("shuffle_state", None)
|
||||||
|
self.is_playing = data.pop("is_playing", None)
|
||||||
|
self.device = Device(data=data.pop("device", None))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Player: {self.user!r}>"
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
return self.__user
|
||||||
|
|
||||||
|
# Public methods
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def pause(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Pause playback on the user’s account.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.pause_playback(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def resume(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Resume playback on the user's account.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.play_playback(None, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def seek(self, pos, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Seeks to the given position in the user’s currently playing track.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pos : int
|
||||||
|
The position in milliseconds to seek to.
|
||||||
|
Must be a positive number.
|
||||||
|
Passing in a position that is greater than the length of the track will cause the player to start playing the next song.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.seek_playback(pos, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def set_repeat(self, state, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Set the repeat mode for the user’s playback.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
state : str
|
||||||
|
Options are repeat-track, repeat-context, and off
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.repeat_playback(state, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def set_volume(self, volume: int, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Set the volume for the user’s current playback device.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
volume : int
|
||||||
|
The volume to set. Must be a value from 0 to 100 inclusive.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.set_playback_volume(volume, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def next(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Skips to next track in the user’s queue.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.skip_next(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def previous(self, *, device: Optional[SomeDevice] = None):
|
||||||
|
"""Skips to previous track in the user’s queue.
|
||||||
|
|
||||||
|
Note that this will ALWAYS skip to the previous track, regardless of the current track’s progress.
|
||||||
|
Returning to the start of the current track should be performed using :meth:`seek`
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
return await self.user.http.skip_previous(device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def enqueue(self, uri: SomeURI, device: Optional[SomeDevice] = None):
|
||||||
|
"""Add an item to the end of the user’s current playback queue.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
uri : Union[:class:`spotify.URIBase`, :class:`str`]
|
||||||
|
The uri of the item to add to the queue. Must be a track or an
|
||||||
|
episode uri.
|
||||||
|
device_id : Optional[Union[Device, :class:`str`]]
|
||||||
|
The id of the device this command is targeting. If not supplied,
|
||||||
|
the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str]
|
||||||
|
if device is not None:
|
||||||
|
if not isinstance(device, (Device, str)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected `device` to either be a spotify.Device or a string. got {type(device)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
device_id = str(device)
|
||||||
|
else:
|
||||||
|
device_id = None
|
||||||
|
|
||||||
|
await self.user.http.playback_queue(uri=str(uri), device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def play(
|
||||||
|
self,
|
||||||
|
*uris: SomeURIs,
|
||||||
|
offset: Optional[Offset] = 0,
|
||||||
|
device: Optional[SomeDevice] = None,
|
||||||
|
):
|
||||||
|
"""Start a new context or resume current playback on the user’s active device.
|
||||||
|
|
||||||
|
The method treats a single argument as a Spotify context, such as a Artist, Album and playlist objects/URI.
|
||||||
|
When called with multiple positional arguments they are interpreted as a array of Spotify Track objects/URIs.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
*uris : SomeURI
|
||||||
|
When a single argument is passed in that argument is treated
|
||||||
|
as a context (except if it is a track or track uri).
|
||||||
|
Valid contexts are: albums, artists, playlists.
|
||||||
|
Album, Artist and Playlist objects are accepted too.
|
||||||
|
Otherwise when multiple arguments are passed in they,
|
||||||
|
A sequence of Spotify Tracks or Track URIs to play.
|
||||||
|
offset : Optional[:obj:`Offset`]
|
||||||
|
Indicates from where in the context playback should start.
|
||||||
|
Only available when `context` corresponds to an album or playlist object,
|
||||||
|
or when the `uris` parameter is used. when an integer offset is zero based and can’t be negative.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
context_uri: Union[List[str], str]
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(uris) > 1
|
||||||
|
or isinstance(uris[0], Track)
|
||||||
|
or (isinstance(uris[0], str) and "track" in uris[0])
|
||||||
|
):
|
||||||
|
# Regular uris paramter
|
||||||
|
context_uri = [str(uri) for uri in uris]
|
||||||
|
else:
|
||||||
|
# Treat it as a context URI
|
||||||
|
context_uri = str(uris[0])
|
||||||
|
|
||||||
|
device_id: Optional[str]
|
||||||
|
if device is not None:
|
||||||
|
if not isinstance(device, (Device, str)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected `device` to either be a spotify.Device or a string. got {type(device)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
device_id = str(device)
|
||||||
|
else:
|
||||||
|
device_id = None
|
||||||
|
|
||||||
|
await self.user.http.play_playback(
|
||||||
|
context_uri, offset=offset, device_id=device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def shuffle(
|
||||||
|
self, state: Optional[bool] = None, *, device: Optional[SomeDevice] = None
|
||||||
|
):
|
||||||
|
"""shuffle on or off for user’s playback.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
state : Optional[bool]
|
||||||
|
if `True` then Shuffle user’s playback.
|
||||||
|
else if `False` do not shuffle user’s playback.
|
||||||
|
device : Optional[:obj:`SomeDevice`]
|
||||||
|
The Device object or id of the device this command is targeting.
|
||||||
|
If not supplied, the user’s currently active device is the target.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.shuffle_playback(state, device_id=device_id)
|
||||||
|
|
||||||
|
@set_required_scopes("user-modify-playback-state")
|
||||||
|
async def transfer(self, device: SomeDevice, ensure_playback: bool = False):
|
||||||
|
"""Transfer playback to a new device and determine if it should start playing.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device : :obj:`SomeDevice`
|
||||||
|
The device on which playback should be started/transferred.
|
||||||
|
ensure_playback : bool
|
||||||
|
if `True` ensure playback happens on new device.
|
||||||
|
else keep the current playback state.
|
||||||
|
"""
|
||||||
|
device_id: Optional[str] = str(device) if device is not None else None
|
||||||
|
await self.user.http.transfer_player(device_id=device_id, play=ensure_playback)
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
from functools import partial
|
||||||
|
from itertools import islice
|
||||||
|
from typing import List, Optional, Union, Callable, Tuple, Iterable, TYPE_CHECKING, Any, Dict, Set
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from ..http import HTTPUserClient, HTTPClient
|
||||||
|
from . import AsyncIterable, URIBase, Track, PlaylistTrack, Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
class MutableTracks:
|
||||||
|
__slots__ = (
|
||||||
|
"playlist",
|
||||||
|
"tracks",
|
||||||
|
"was_empty",
|
||||||
|
"is_empty",
|
||||||
|
"replace_tracks",
|
||||||
|
"get_all_tracks",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, playlist: "Playlist") -> None:
|
||||||
|
self.playlist = playlist
|
||||||
|
self.tracks = tracks = getattr(playlist, "_Playlist__tracks")
|
||||||
|
|
||||||
|
if tracks is not None:
|
||||||
|
self.was_empty = self.is_empty = not tracks
|
||||||
|
|
||||||
|
self.replace_tracks = playlist.replace_tracks
|
||||||
|
self.get_all_tracks = playlist.get_all_tracks
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if self.tracks is None:
|
||||||
|
self.tracks = tracks = list(await self.get_all_tracks())
|
||||||
|
self.was_empty = self.is_empty = not tracks
|
||||||
|
else:
|
||||||
|
tracks = list(self.tracks)
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
async def __aexit__(self, typ, value, traceback):
|
||||||
|
if self.was_empty and self.is_empty:
|
||||||
|
# the tracks were empty and is still empty.
|
||||||
|
# skip the api call.
|
||||||
|
return
|
||||||
|
|
||||||
|
tracks = self.tracks
|
||||||
|
|
||||||
|
await self.replace_tracks(*tracks)
|
||||||
|
setattr(self.playlist, "_Playlist__tracks", tuple(self.tracks))
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Playlist.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
collaborative : :class:`bool`
|
||||||
|
Returns true if context is not search and the owner allows other users to modify the playlist. Otherwise returns false.
|
||||||
|
description : :class:`str`
|
||||||
|
The playlist description. Only returned for modified, verified playlists, otherwise null.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL.
|
||||||
|
followers : :class:`int`
|
||||||
|
The total amount of followers
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint providing full details of the playlist.
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID for the playlist.
|
||||||
|
images : List[:class:`spotify.Image`]
|
||||||
|
Images for the playlist.
|
||||||
|
The array may be empty or contain up to three images.
|
||||||
|
The images are returned by size in descending order.
|
||||||
|
If returned, the source URL for the image ( url ) is temporary and will expire in less than a day.
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the playlist.
|
||||||
|
owner : :class:`spotify.User`
|
||||||
|
The user who owns the playlist
|
||||||
|
public : :class`bool`
|
||||||
|
The playlist’s public/private status:
|
||||||
|
true the playlist is public,
|
||||||
|
false the playlist is private,
|
||||||
|
null the playlist status is not relevant.
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The version identifier for the current playlist.
|
||||||
|
tracks : Optional[Tuple[:class:`PlaylistTrack`]]
|
||||||
|
A tuple of :class:`PlaylistTrack` objects or `None`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"collaborative",
|
||||||
|
"description",
|
||||||
|
"url",
|
||||||
|
"followers",
|
||||||
|
"href",
|
||||||
|
"id",
|
||||||
|
"images",
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"public",
|
||||||
|
"uri",
|
||||||
|
"total_tracks",
|
||||||
|
"__client",
|
||||||
|
"__http",
|
||||||
|
"__tracks",
|
||||||
|
)
|
||||||
|
|
||||||
|
__tracks: Optional[Tuple[PlaylistTrack, ...]]
|
||||||
|
__http: Union[HTTPUserClient, HTTPClient]
|
||||||
|
total_tracks: Optional[int]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: "spotify.Client",
|
||||||
|
data: Union[dict, "Playlist"],
|
||||||
|
*,
|
||||||
|
http: Optional[HTTPClient] = None,
|
||||||
|
):
|
||||||
|
self.__client = client
|
||||||
|
self.__http = http or client.http
|
||||||
|
|
||||||
|
assert self.__http is not None
|
||||||
|
|
||||||
|
self.__tracks = None
|
||||||
|
self.total_tracks = None
|
||||||
|
|
||||||
|
if not isinstance(data, (Playlist, dict)):
|
||||||
|
raise TypeError("data must be a Playlist instance or a dict.")
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self.__from_raw(data)
|
||||||
|
else:
|
||||||
|
for name in filter((lambda name: name[0] != "_"), Playlist.__slots__):
|
||||||
|
setattr(self, name, getattr(data, name))
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = PlaylistTrack
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
client.http.get_playlist_tracks, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<spotify.Playlist: {getattr(self, "name", None) or self.id}>'
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return self.total_tracks
|
||||||
|
|
||||||
|
# Internals
|
||||||
|
|
||||||
|
def __from_raw(self, data: dict) -> None:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
client = self.__client
|
||||||
|
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
self.images = tuple(Image(**image) for image in data.pop("images", []))
|
||||||
|
self.owner = User(client, data=data.pop("owner"))
|
||||||
|
|
||||||
|
self.public = data.pop("public")
|
||||||
|
self.collaborative = data.pop("collaborative")
|
||||||
|
self.description = data.pop("description", None)
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.name = data.pop("name")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
|
||||||
|
tracks: Optional[Tuple[PlaylistTrack, ...]] = (
|
||||||
|
tuple(PlaylistTrack(client, item) for item in data["tracks"]["items"])
|
||||||
|
if "items" in data["tracks"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.__tracks = tracks
|
||||||
|
|
||||||
|
self.total_tracks = (
|
||||||
|
data["tracks"]["total"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track retrieval
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_tracks(
|
||||||
|
self, *, limit: Optional[int] = 20, offset: Optional[int] = 0
|
||||||
|
) -> Tuple[PlaylistTrack, ...]:
|
||||||
|
"""Get a fraction of a playlists tracks.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many tracks to retrieve for this playlist (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : Tuple[PlaylistTrack]
|
||||||
|
The tracks of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.get_playlist_tracks(
|
||||||
|
self.id, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return tuple(PlaylistTrack(self.__client, item) for item in data["items"])
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
async def get_all_tracks(self) -> Tuple[PlaylistTrack, ...]:
|
||||||
|
"""Get all playlist tracks from the playlist.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : Tuple[:class:`PlaylistTrack`]
|
||||||
|
The playlists tracks.
|
||||||
|
"""
|
||||||
|
tracks: List[PlaylistTrack] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
if self.total_tracks is None:
|
||||||
|
self.total_tracks = (
|
||||||
|
await self.__http.get_playlist_tracks(self.id, limit=1, offset=0)
|
||||||
|
)["total"]
|
||||||
|
|
||||||
|
while len(tracks) < self.total_tracks:
|
||||||
|
data = await self.__http.get_playlist_tracks(
|
||||||
|
self.id, limit=50, offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks += [PlaylistTrack(self.__client, item) for item in data["items"]]
|
||||||
|
offset += 50
|
||||||
|
|
||||||
|
self.total_tracks = len(tracks)
|
||||||
|
return tuple(tracks)
|
||||||
|
|
||||||
|
# Playlist structure modification
|
||||||
|
|
||||||
|
# Basic api wrapping
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def add_tracks(self, *tracks) -> str:
|
||||||
|
"""Add one or more tracks to a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to add to the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.add_playlist_tracks(
|
||||||
|
self.id, tracks=[str(track) for track in tracks]
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def remove_tracks(
|
||||||
|
self, *tracks: Union[str, Track, Tuple[Union[str, Track], List[int]]]
|
||||||
|
):
|
||||||
|
"""Remove one or more tracks from a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to remove from the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
tracks_: List[Union[str, Dict[str, Union[str, Set[int]]]]] = []
|
||||||
|
|
||||||
|
for part in tracks:
|
||||||
|
if not isinstance(part, (Track, str, tuple)):
|
||||||
|
raise TypeError(
|
||||||
|
"Track argument of tracks parameter must be a Track instance, string or a tuple of those and an iterator of positive integers."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(part, (Track, str)):
|
||||||
|
tracks_.append(str(part))
|
||||||
|
continue
|
||||||
|
|
||||||
|
track, positions, = part
|
||||||
|
|
||||||
|
if not isinstance(track, (Track, str)):
|
||||||
|
raise TypeError(
|
||||||
|
"Track argument of tuple track parameter must be a Track instance or a string."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(positions, "__iter__"):
|
||||||
|
raise TypeError("Positions element of track tuple must be a iterator.")
|
||||||
|
|
||||||
|
if not all(isinstance(index, int) for index in positions):
|
||||||
|
raise TypeError("Members of the positions iterator must be integers.")
|
||||||
|
|
||||||
|
elem: Dict[str, Union[str, Set[int]]] = {"uri": str(track), "positions": set(positions)}
|
||||||
|
tracks_.append(elem)
|
||||||
|
|
||||||
|
data = await self.__http.remove_playlist_tracks(self.id, tracks=tracks_)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def replace_tracks(self, *tracks: Union[Track, PlaylistTrack, str]) -> None:
|
||||||
|
"""Replace all the tracks in a playlist, overwriting its existing tracks.
|
||||||
|
|
||||||
|
This powerful request can be useful for replacing tracks, re-ordering existing tracks, or clearing the playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Iterable[Union[:class:`str`, :class:`Track`]]
|
||||||
|
Tracks to place in the playlist
|
||||||
|
"""
|
||||||
|
bucket: List[str] = []
|
||||||
|
for track in tracks:
|
||||||
|
if not isinstance(track, (str, Track)):
|
||||||
|
raise TypeError(
|
||||||
|
f"tracks must be a iterable of strings or Track instances. Got {type(track)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
bucket.append(str(track))
|
||||||
|
|
||||||
|
body: Tuple[str, ...] = tuple(bucket)
|
||||||
|
|
||||||
|
head: Tuple[str, ...]
|
||||||
|
tail: Tuple[str, ...]
|
||||||
|
head, tail = body[:100], body[100:]
|
||||||
|
|
||||||
|
if head:
|
||||||
|
await self.__http.replace_playlist_tracks(self.id, tracks=head)
|
||||||
|
|
||||||
|
while tail:
|
||||||
|
head, tail = tail[:100], tail[100:]
|
||||||
|
await self.extend(head)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def reorder_tracks(
|
||||||
|
self,
|
||||||
|
start: int,
|
||||||
|
insert_before: int,
|
||||||
|
length: int = 1,
|
||||||
|
*,
|
||||||
|
snapshot_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Reorder a track or a group of tracks in a playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
start : int
|
||||||
|
The position of the first track to be reordered.
|
||||||
|
insert_before : int
|
||||||
|
The position where the tracks should be inserted.
|
||||||
|
length : Optional[int]
|
||||||
|
The amount of tracks to be reordered. Defaults to 1 if not set.
|
||||||
|
snapshot_id : str
|
||||||
|
The playlist’s snapshot ID against which you want to make the changes.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : str
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.__http.reorder_playlists_tracks(
|
||||||
|
self.id, start, length, insert_before, snapshot_id=snapshot_id
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
# Library functionality.
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def clear(self):
|
||||||
|
"""Clear the playlists tracks.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This is a desctructive operation and can not be reversed!
|
||||||
|
"""
|
||||||
|
await self.__http.replace_playlist_tracks(self.id, tracks=[])
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def extend(self, tracks: Union["Playlist", Iterable[Union[Track, str]]]):
|
||||||
|
"""Extend a playlists tracks with that of another playlist or a list of Track/Track URIs.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tracks : Union["Playlist", List[Union[Track, str]]]
|
||||||
|
Tracks to add to the playlist, acceptable values are:
|
||||||
|
- A :class:`spotify.Playlist` object
|
||||||
|
- A :class:`list` of :class:`spotify.Track` objects or Track URIs
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : str
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
bucket: Iterable[Union[Track, str]]
|
||||||
|
|
||||||
|
if isinstance(tracks, Playlist):
|
||||||
|
bucket = await tracks.get_all_tracks()
|
||||||
|
|
||||||
|
elif not hasattr(tracks, "__iter__"):
|
||||||
|
raise TypeError(
|
||||||
|
f"`tracks` was an invalid type, expected any of: Playlist, Iterable[Union[Track, str]], instead got {type(tracks)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
bucket = list(tracks)
|
||||||
|
|
||||||
|
gen: Iterable[str] = (str(track) for track in bucket)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
head: List[str] = list(islice(gen, 0, 100))
|
||||||
|
|
||||||
|
if not head:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.__http.add_playlist_tracks(self.id, tracks=head)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def insert(self, index, obj: Union[PlaylistTrack, Track]) -> None:
|
||||||
|
"""Insert an object before the index.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
if not isinstance(obj, (PlaylistTrack, Track)):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected a PlaylistTrack or Track object instead got {obj!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.insert(index, obj)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def pop(self, index: int = -1) -> PlaylistTrack:
|
||||||
|
"""Remove and return the track at the specified index.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist_track : :class:`PlaylistTrack`
|
||||||
|
The track that was removed.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
IndexError
|
||||||
|
If there are no tracks or the index is out of range.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
return tracks.pop(index)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def sort(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
key: Optional[Callable[[PlaylistTrack], bool]] = None,
|
||||||
|
reverse: Optional[bool] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Stable sort the playlist in place.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.sort(key=key, reverse=reverse)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def remove(self, value: Union[PlaylistTrack, Track]) -> None:
|
||||||
|
"""Remove the first occurence of the value.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
-------
|
||||||
|
ValueError
|
||||||
|
If the value is not present.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.remove(value)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def copy(self) -> "Playlist":
|
||||||
|
"""Return a shallow copy of the playlist object.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist : :class:`Playlist`
|
||||||
|
The playlist object copy.
|
||||||
|
"""
|
||||||
|
return Playlist(client=self.__client, data=self, http=self.__http)
|
||||||
|
|
||||||
|
@set_required_scopes("playlist-modify-public", "playlist-modify-private")
|
||||||
|
async def reverse(self) -> None:
|
||||||
|
"""Reverse the playlist in place.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This method will mutate the current
|
||||||
|
playlist object, and the spotify Playlist.
|
||||||
|
"""
|
||||||
|
async with MutableTracks(self) as tracks:
|
||||||
|
tracks.reverse()
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Source implementation for spotify Tracks, and any other semantically relevent, implementation."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from itertools import starmap
|
||||||
|
|
||||||
|
from ..oauth import set_required_scopes
|
||||||
|
from . import URIBase, Image, Artist
|
||||||
|
|
||||||
|
|
||||||
|
class Track(URIBase): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify Track object.
|
||||||
|
|
||||||
|
Attribtues
|
||||||
|
----------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID for the track.
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the track.
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint providing full details of the track.
|
||||||
|
uri : :class:`str`
|
||||||
|
The Spotify URI for the track.
|
||||||
|
duration : int
|
||||||
|
The track length in milliseconds.
|
||||||
|
explicit : bool
|
||||||
|
Whether or not the track has explicit
|
||||||
|
`True` if it does.
|
||||||
|
`False` if it does not (or unknown)
|
||||||
|
disc_number : int
|
||||||
|
The disc number (usually 1 unless the album consists of more than one disc).
|
||||||
|
track_number : int
|
||||||
|
The number of the track.
|
||||||
|
If an album has several discs, the track number is the number on the specified disc.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL for this Track
|
||||||
|
is_local : bool
|
||||||
|
Whether or not the track is from a local file.
|
||||||
|
popularity : int
|
||||||
|
POPULARITY
|
||||||
|
preview_url : :class:`str`
|
||||||
|
The preview URL for this Track.
|
||||||
|
images : List[Image]
|
||||||
|
The images of the Track.
|
||||||
|
markets : List[:class:`str`]
|
||||||
|
The available markets for the Track.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, data, album=None):
|
||||||
|
from .album import Album
|
||||||
|
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
self.artists = artists = list(
|
||||||
|
Artist(client, artist) for artist in data.pop("artists", [])
|
||||||
|
)
|
||||||
|
self.artist = artists[-1] if artists else None
|
||||||
|
|
||||||
|
album_ = data.pop("album", None)
|
||||||
|
self.album = Album(client, album_) if album_ else album
|
||||||
|
|
||||||
|
self.id = data.pop("id", None) # pylint: disable=invalid-name
|
||||||
|
self.name = data.pop("name", None)
|
||||||
|
self.href = data.pop("href", None)
|
||||||
|
self.uri = data.pop("uri", None)
|
||||||
|
self.duration = data.pop("duration_ms", None)
|
||||||
|
self.explicit = data.pop("explicit", None)
|
||||||
|
self.disc_number = data.pop("disc_number", None)
|
||||||
|
self.track_number = data.pop("track_number", None)
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.is_local = data.pop("is_local", None)
|
||||||
|
self.popularity = data.pop("popularity", None)
|
||||||
|
self.preview_url = data.pop("preview_url", None)
|
||||||
|
self.markets = data.pop("available_markets", [])
|
||||||
|
|
||||||
|
if "images" in data:
|
||||||
|
self.images = list(starmap(Image, data.pop("images")))
|
||||||
|
else:
|
||||||
|
self.images = self.album.images.copy() if self.album is not None else []
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.Track: {self.name!r}>"
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
def audio_analysis(self):
|
||||||
|
"""Get a detailed audio analysis for the track."""
|
||||||
|
return self.__client.http.track_audio_analysis(self.id)
|
||||||
|
|
||||||
|
@set_required_scopes(None)
|
||||||
|
def audio_features(self):
|
||||||
|
"""Get audio feature information for the track."""
|
||||||
|
return self.__client.http.track_audio_features(self.id)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistTrack(Track, URIBase):
|
||||||
|
"""A Track on a Playlist.
|
||||||
|
|
||||||
|
Like a regular :class:`Track` but has some additional attributes.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
added_by : :class:`str`
|
||||||
|
The Spotify user who added the track.
|
||||||
|
is_local : bool
|
||||||
|
Whether this track is a local file or not.
|
||||||
|
added_at : datetime.datetime
|
||||||
|
The datetime of when the track was added to the playlist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("added_at", "added_by", "is_local")
|
||||||
|
|
||||||
|
def __init__(self, client, data):
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
super().__init__(client, data["track"])
|
||||||
|
|
||||||
|
self.added_by = User(client, data["added_by"])
|
||||||
|
self.added_at = datetime.datetime.strptime(
|
||||||
|
data["added_at"], "%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
self.is_local = data["is_local"]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.PlaylistTrack: {self.name!r}>"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Type annotation aliases for other Spotify models."""
|
||||||
|
|
||||||
|
from typing import Union, Sequence
|
||||||
|
|
||||||
|
from .base import URIBase
|
||||||
|
|
||||||
|
SomeURI = Union[URIBase, str]
|
||||||
|
SomeURIs = Sequence[Union[URIBase, str]]
|
||||||
|
OneOrMoreURIs = Union[SomeURI, Sequence[SomeURI]]
|
||||||
|
|
@ -0,0 +1,562 @@
|
||||||
|
"""Source implementation for a spotify User"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from functools import partial
|
||||||
|
from base64 import b64encode
|
||||||
|
from typing import (
|
||||||
|
Optional,
|
||||||
|
Dict,
|
||||||
|
Union,
|
||||||
|
List,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..utils import to_id
|
||||||
|
from ..http import HTTPUserClient
|
||||||
|
from . import (
|
||||||
|
AsyncIterable,
|
||||||
|
URIBase,
|
||||||
|
Image,
|
||||||
|
Device,
|
||||||
|
Context,
|
||||||
|
Player,
|
||||||
|
Playlist,
|
||||||
|
Track,
|
||||||
|
Artist,
|
||||||
|
Library,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
T = TypeVar("T", Artist, Track) # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_http(func):
|
||||||
|
func.__ensure_http__ = True
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
|
class User(URIBase, AsyncIterable): # pylint: disable=too-many-instance-attributes
|
||||||
|
"""A Spotify User.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify user ID for the user.
|
||||||
|
uri : :class:`str`
|
||||||
|
The Spotify URI for the user.
|
||||||
|
url : :class:`str`
|
||||||
|
The open.spotify URL.
|
||||||
|
href : :class:`str`
|
||||||
|
A link to the Web API endpoint for this user.
|
||||||
|
display_name : :class:`str`
|
||||||
|
The name displayed on the user’s profile.
|
||||||
|
`None` if not available.
|
||||||
|
followers : :class:`int`
|
||||||
|
The total number of followers.
|
||||||
|
images : List[:class:`Image`]
|
||||||
|
The user’s profile image.
|
||||||
|
email : :class:`str`
|
||||||
|
The user’s email address, as entered by the user when creating their account.
|
||||||
|
country : :class:`str`
|
||||||
|
The country of the user, as set in the user’s account profile. An ISO 3166-1 alpha-2 country code.
|
||||||
|
birthdate : :class:`str`
|
||||||
|
The user’s date-of-birth.
|
||||||
|
product : :class:`str`
|
||||||
|
The user’s Spotify subscription level: “premium”, “free”, etc.
|
||||||
|
(The subscription level “open” can be considered the same as “free”.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: "spotify.Client", data: dict, **kwargs):
|
||||||
|
self.__client = self.client = client
|
||||||
|
|
||||||
|
if "http" not in kwargs:
|
||||||
|
self.library = None
|
||||||
|
self.http = client.http
|
||||||
|
else:
|
||||||
|
self.http = kwargs.pop("http")
|
||||||
|
self.library = Library(client, self)
|
||||||
|
|
||||||
|
# Public user object attributes
|
||||||
|
self.id = data.pop("id") # pylint: disable=invalid-name
|
||||||
|
self.uri = data.pop("uri")
|
||||||
|
self.url = data.pop("external_urls").get("spotify", None)
|
||||||
|
self.display_name = data.pop("display_name", None)
|
||||||
|
self.href = data.pop("href")
|
||||||
|
self.followers = data.pop("followers", {}).get("total", None)
|
||||||
|
self.images = list(Image(**image) for image in data.pop("images", []))
|
||||||
|
|
||||||
|
# Private user object attributes
|
||||||
|
self.email = data.pop("email", None)
|
||||||
|
self.country = data.pop("country", None)
|
||||||
|
self.birthdate = data.pop("birthdate", None)
|
||||||
|
self.product = data.pop("product", None)
|
||||||
|
|
||||||
|
# AsyncIterable attrs
|
||||||
|
self.__aiter_klass__ = Playlist
|
||||||
|
self.__aiter_fetch__ = partial(
|
||||||
|
self.__client.http.get_playlists, self.id, limit=50
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotify.User: {(self.display_name or self.id)!r}>"
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
value = object.__getattribute__(self, attr)
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasattr(value, "__ensure_http__")
|
||||||
|
and getattr(self, "http", None) is not None
|
||||||
|
):
|
||||||
|
|
||||||
|
@functools.wraps(value)
|
||||||
|
def _raise(*args, **kwargs):
|
||||||
|
raise AttributeError(
|
||||||
|
"User has not HTTP presence to perform API requests."
|
||||||
|
)
|
||||||
|
|
||||||
|
return _raise
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "User":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, _, __, ___):
|
||||||
|
await self.http.close()
|
||||||
|
|
||||||
|
# Internals
|
||||||
|
|
||||||
|
async def _get_top(self, klass: Type[T], kwargs: dict) -> List[T]:
|
||||||
|
target = {Artist: "artists", Track: "tracks"}[klass]
|
||||||
|
data = {
|
||||||
|
key: value
|
||||||
|
for key, value in kwargs.items()
|
||||||
|
if key in ("limit", "offset", "time_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await self.http.top_artists_or_tracks(target, **data) # type: ignore
|
||||||
|
|
||||||
|
return [klass(self.__client, item) for item in resp["items"]]
|
||||||
|
|
||||||
|
### Alternate constructors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_code(
|
||||||
|
cls, client: "spotify.Client", code: str, *, redirect_uri: str,
|
||||||
|
):
|
||||||
|
"""Create a :class:`User` object from an authorization code.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
code : :class:`str`
|
||||||
|
The authorization code to use to further authenticate the user.
|
||||||
|
redirect_uri : :class:`str`
|
||||||
|
The rediriect URI to use in tandem with the authorization code.
|
||||||
|
"""
|
||||||
|
route = ("POST", "https://accounts.spotify.com/api/token")
|
||||||
|
payload = {
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
|
||||||
|
client_id = client.http.client_id
|
||||||
|
client_secret = client.http.client_secret
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {b64encode(':'.join((client_id, client_secret)).encode()).decode()}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = await client.http.request(route, headers=headers, params=payload)
|
||||||
|
|
||||||
|
token = raw["access_token"]
|
||||||
|
refresh_token = raw["refresh_token"]
|
||||||
|
|
||||||
|
return await cls.from_token(client, token, refresh_token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_token(
|
||||||
|
cls,
|
||||||
|
client: "spotify.Client",
|
||||||
|
token: Optional[str],
|
||||||
|
refresh_token: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Create a :class:`User` object from an access token.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
token : :class:`str`
|
||||||
|
The access token to use for http requests.
|
||||||
|
refresh_token : :class:`str`
|
||||||
|
Used to acquire new token when it expires.
|
||||||
|
"""
|
||||||
|
client_id = client.http.client_id
|
||||||
|
client_secret = client.http.client_secret
|
||||||
|
http = HTTPUserClient(client_id, client_secret, token, refresh_token)
|
||||||
|
data = await http.current_user()
|
||||||
|
return cls(client, data=data, http=http)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_refresh_token(cls, client: "spotify.Client", refresh_token: str):
|
||||||
|
"""Create a :class:`User` object from a refresh token.
|
||||||
|
It will poll the spotify API for a new access token and
|
||||||
|
use that to initialize the spotify user.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : :class:`spotify.Client`
|
||||||
|
The spotify client to associate the user with.
|
||||||
|
refresh_token: str
|
||||||
|
Used to acquire token.
|
||||||
|
"""
|
||||||
|
return await cls.from_token(client, None, refresh_token)
|
||||||
|
|
||||||
|
### Contextual methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def currently_playing(self) -> Dict[str, Union[Track, Context, str]]:
|
||||||
|
"""Get the users currently playing track.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
context, track : Dict[str, Union[Track, Context, str]]
|
||||||
|
A tuple of the context and track.
|
||||||
|
"""
|
||||||
|
data = await self.http.currently_playing() # type: ignore
|
||||||
|
|
||||||
|
if "item" in data:
|
||||||
|
context = data.pop("context", None)
|
||||||
|
|
||||||
|
if context is not None:
|
||||||
|
data["context"] = Context(context)
|
||||||
|
else:
|
||||||
|
data["context"] = None
|
||||||
|
|
||||||
|
data["item"] = Track(self.__client, data.get("item", {}) or {})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_player(self) -> Player:
|
||||||
|
"""Get information about the users current playback.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
player : :class:`Player`
|
||||||
|
A player object representing the current playback.
|
||||||
|
"""
|
||||||
|
player = Player(self.__client, self, await self.http.current_player()) # type: ignore
|
||||||
|
return player
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_devices(self) -> List[Device]:
|
||||||
|
"""Get information about the users avaliable devices.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
devices : List[:class:`Device`]
|
||||||
|
The devices the user has available.
|
||||||
|
"""
|
||||||
|
data = await self.http.available_devices() # type: ignore
|
||||||
|
return [Device(item) for item in data["devices"]]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def recently_played(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
before: Optional[str] = None,
|
||||||
|
after: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Union[Track, Context, str]]]:
|
||||||
|
"""Get tracks from the current users recently played tracks.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist_history : List[Dict[:class:`str`, Union[Track, Context, :class:`str`]]]
|
||||||
|
A list of playlist history object.
|
||||||
|
Each object is a dict with a timestamp, track and context field.
|
||||||
|
"""
|
||||||
|
data = await self.http.recently_played(limit=limit, before=before, after=after) # type: ignore
|
||||||
|
client = self.__client
|
||||||
|
|
||||||
|
# List[T] where T: {'track': Track, 'content': Context: 'timestamp': ISO8601}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"played_at": track.get("played_at"),
|
||||||
|
"context": Context(track.get("context", {}) or {}),
|
||||||
|
"track": Track(client, track.get("track", {}) or {}),
|
||||||
|
}
|
||||||
|
for track in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
### Playlist track methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def add_tracks(self, playlist: Union[str, Playlist], *tracks) -> str:
|
||||||
|
"""Add one or more tracks to a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to add to the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.add_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=[str(track) for track in tracks]
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def replace_tracks(self, playlist, *tracks) -> None:
|
||||||
|
"""Replace all the tracks in a playlist, overwriting its existing tracks.
|
||||||
|
|
||||||
|
This powerful request can be useful for replacing tracks, re-ordering existing tracks, or clearing the playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, PLaylist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to place in the playlist
|
||||||
|
"""
|
||||||
|
await self.http.replace_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=",".join(str(track) for track in tracks)
|
||||||
|
)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def remove_tracks(self, playlist, *tracks):
|
||||||
|
"""Remove one or more tracks from a user’s playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
tracks : Sequence[Union[:class:`str`, Track]]
|
||||||
|
Tracks to remove from the playlist
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.remove_playlist_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), tracks=(str(track) for track in tracks)
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def reorder_tracks(
|
||||||
|
self, playlist, start, insert_before, length=1, *, snapshot_id=None
|
||||||
|
):
|
||||||
|
"""Reorder a track or a group of tracks in a playlist.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
start : int
|
||||||
|
The position of the first track to be reordered.
|
||||||
|
insert_before : int
|
||||||
|
The position where the tracks should be inserted.
|
||||||
|
length : Optional[int]
|
||||||
|
The amount of tracks to be reordered. Defaults to 1 if not set.
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The playlist’s snapshot ID against which you want to make the changes.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
snapshot_id : :class:`str`
|
||||||
|
The snapshot id of the playlist.
|
||||||
|
"""
|
||||||
|
data = await self.http.reorder_playlists_tracks( # type: ignore
|
||||||
|
to_id(str(playlist)), start, length, insert_before, snapshot_id=snapshot_id
|
||||||
|
)
|
||||||
|
return data["snapshot_id"]
|
||||||
|
|
||||||
|
### Playlist methods
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def edit_playlist(
|
||||||
|
self, playlist, *, name=None, public=None, collaborative=None, description=None
|
||||||
|
):
|
||||||
|
"""Change a playlist’s name and public/private, collaborative state and description.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
name : Optional[:class:`str`]
|
||||||
|
The new name of the playlist.
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
collaborative : Optional[bool]
|
||||||
|
If `True`, the playlist will become collaborative and other users will be able to modify the playlist.
|
||||||
|
description : Optional[:class:`str`]
|
||||||
|
The new playlist description
|
||||||
|
"""
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"name": name,
|
||||||
|
"public": public,
|
||||||
|
"collaborative": collaborative,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.http.change_playlist_details(to_id(str(playlist)), **kwargs) # type: ignore
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def create_playlist(
|
||||||
|
self, name, *, public=True, collaborative=False, description=None
|
||||||
|
):
|
||||||
|
"""Create a playlist for a Spotify user.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name : :class:`str`
|
||||||
|
The name of the playlist.
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
collaborative : Optional[bool]
|
||||||
|
If `True`, the playlist will become collaborative and other users will be able to modify the playlist.
|
||||||
|
description : Optional[:class:`str`]
|
||||||
|
The playlist description
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlist : :class:`Playlist`
|
||||||
|
The playlist that was created.
|
||||||
|
"""
|
||||||
|
data = {"name": name, "public": public, "collaborative": collaborative}
|
||||||
|
|
||||||
|
if description:
|
||||||
|
data["description"] = description
|
||||||
|
|
||||||
|
playlist_data = await self.http.create_playlist(self.id, **data) # type: ignore
|
||||||
|
return Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def follow_playlist(
|
||||||
|
self, playlist: Union[str, Playlist], *, public: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""follow a playlist
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
playlist : Union[:class:`str`, Playlist]
|
||||||
|
The playlist to modify
|
||||||
|
public : Optional[bool]
|
||||||
|
The public/private status of the playlist.
|
||||||
|
`True` for public, `False` for private.
|
||||||
|
"""
|
||||||
|
await self.http.follow_playlist(to_id(str(playlist)), public=public) # type: ignore
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_playlists(
|
||||||
|
self, *, limit: int = 20, offset: int = 0
|
||||||
|
) -> List[Playlist]:
|
||||||
|
"""get the users playlists from spotify.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The limit on how many playlists to retrieve for this user (default is 20).
|
||||||
|
offset : Optional[int]
|
||||||
|
The offset from where the api should start from in the playlists.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlists : List[Playlist]
|
||||||
|
A list of the users playlists.
|
||||||
|
"""
|
||||||
|
data = await self.http.get_playlists(self.id, limit=limit, offset=offset) # type: ignore
|
||||||
|
|
||||||
|
return [
|
||||||
|
Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
for playlist_data in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def get_all_playlists(self) -> List[Playlist]:
|
||||||
|
"""Get all of the users playlists from spotify.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
playlists : List[:class:`Playlist`]
|
||||||
|
A list of the users playlists.
|
||||||
|
"""
|
||||||
|
playlists: List[Playlist] = []
|
||||||
|
total = None
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = await self.http.get_playlists(self.id, limit=50, offset=offset) # type: ignore
|
||||||
|
|
||||||
|
if total is None:
|
||||||
|
total = data["total"]
|
||||||
|
|
||||||
|
offset += 50
|
||||||
|
playlists += [
|
||||||
|
Playlist(self.__client, playlist_data, http=self.http)
|
||||||
|
for playlist_data in data["items"]
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(playlists) >= total:
|
||||||
|
break
|
||||||
|
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def top_artists(self, **data) -> List[Artist]:
|
||||||
|
"""Get the current user’s top artists based on calculated affinity.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The number of entities to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first entity to return. Default: 0
|
||||||
|
time_range : Optional[:class:`str`]
|
||||||
|
Over what time frame the affinities are computed. (long_term, short_term, medium_term)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Artist]
|
||||||
|
The top artists for the user.
|
||||||
|
"""
|
||||||
|
return await self._get_top(Artist, data)
|
||||||
|
|
||||||
|
@ensure_http
|
||||||
|
async def top_tracks(self, **data) -> List[Track]:
|
||||||
|
"""Get the current user’s top tracks based on calculated affinity.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
limit : Optional[int]
|
||||||
|
The number of entities to return. Default: 20. Minimum: 1. Maximum: 50.
|
||||||
|
offset : Optional[int]
|
||||||
|
The index of the first entity to return. Default: 0
|
||||||
|
time_range : Optional[:class:`str`]
|
||||||
|
Over what time frame the affinities are computed. (long_term, short_term, medium_term)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tracks : List[Track]
|
||||||
|
The top tracks for the user.
|
||||||
|
"""
|
||||||
|
return await self._get_top(Track, data)
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
from urllib.parse import quote_plus as quote
|
||||||
|
from typing import Optional, Dict, Iterable, Union, Set, Callable, Tuple, Any
|
||||||
|
|
||||||
|
VALID_SCOPES = (
|
||||||
|
# Playlists
|
||||||
|
"playlist-read-collaborative"
|
||||||
|
"playlist-modify-private"
|
||||||
|
"playlist-modify-public"
|
||||||
|
"playlist-read-private"
|
||||||
|
# Spotify Connect
|
||||||
|
"user-modify-playback-state"
|
||||||
|
"user-read-currently-playing"
|
||||||
|
"user-read-playback-state"
|
||||||
|
# Users
|
||||||
|
"user-read-private"
|
||||||
|
"user-read-email"
|
||||||
|
# Library
|
||||||
|
"user-library-modify"
|
||||||
|
"user-library-read"
|
||||||
|
# Follow
|
||||||
|
"user-follow-modify"
|
||||||
|
"user-follow-read"
|
||||||
|
# Listening History
|
||||||
|
"user-read-recently-played"
|
||||||
|
"user-top-read"
|
||||||
|
# Playback
|
||||||
|
"streaming"
|
||||||
|
"app-remote-control"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_required_scopes(*scopes: Optional[str]) -> Callable:
|
||||||
|
"""A decorator that lets you attach metadata to functions.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
scopes : :class:`str`
|
||||||
|
A series of scopes that are required.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
decorator : :class:`typing.Callable`
|
||||||
|
The decorator that sets the scope metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorate(func) -> Callable:
|
||||||
|
func.__requires_spotify_scopes__ = tuple(scopes)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_scopes(func: Callable[..., Any]) -> Tuple[str, ...]:
|
||||||
|
"""Get the required scopes for a function.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
func : Callable[..., Any]
|
||||||
|
The function to inspect.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
scopes : Tuple[:class:`str`, ...]
|
||||||
|
A tuple of scopes required for a call to succeed.
|
||||||
|
"""
|
||||||
|
if not hasattr(func, "__requires_spotify_scopes__"):
|
||||||
|
raise AttributeError("Scope metadata has not been set for this object!")
|
||||||
|
return func.__requires_spotify_scopes__ # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2:
|
||||||
|
"""Helper object for Spotify OAuth2 operations.
|
||||||
|
|
||||||
|
At a very basic level you can you oauth2 only for authentication.
|
||||||
|
|
||||||
|
>>> oauth2 = OAuth2(client, 'some://redirect/uri')
|
||||||
|
>>> print(oauth2.url)
|
||||||
|
|
||||||
|
Working with scopes:
|
||||||
|
|
||||||
|
>>> oauth2 = OAuth2(client, 'some://redirect/uri', scopes=['user-read-currently-playing'])
|
||||||
|
>>> oauth2.set_scopes(user_read_playback_state=True)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client_id : :class:`str`
|
||||||
|
The client id provided by spotify for the app.
|
||||||
|
redirect_uri : :class:`str`
|
||||||
|
The URI Spotify should redirect to.
|
||||||
|
scopes : Optional[Iterable[:class:`str`], Dict[:class:`str`, :class:`bool`]]
|
||||||
|
The scopes to be requested.
|
||||||
|
state : Optional[:class:`str`]
|
||||||
|
The state used to secure sessions.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
attrs : Dict[:class:`str`, :class:`str`]
|
||||||
|
The attributes used when constructing url parameters
|
||||||
|
parameters : :class:`str`
|
||||||
|
The URL parameters used
|
||||||
|
url : :class:`str`
|
||||||
|
The URL for OAuth2
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BASE = "https://accounts.spotify.com/authorize/?response_type=code&{parameters}"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
*,
|
||||||
|
scopes: Optional[Union[Iterable[str], Dict[str, bool]]] = None,
|
||||||
|
state: str = None,
|
||||||
|
):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
self.state = state
|
||||||
|
self.__scopes: Set[str] = set()
|
||||||
|
|
||||||
|
if scopes is not None:
|
||||||
|
if not isinstance(scopes, dict) and hasattr(scopes, "__iter__"):
|
||||||
|
scopes = {scope: True for scope in scopes}
|
||||||
|
|
||||||
|
if isinstance(scopes, dict):
|
||||||
|
self.set_scopes(**scopes)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"scopes must be an iterable of strings or a dict of string to bools"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<spotfy.OAuth2: client_id={self.client_id!r}, scope={self.scopes!r}>"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
# Alternate constructors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_client(cls, client, *args, **kwargs):
|
||||||
|
"""Construct a OAuth2 object from a `spotify.Client`."""
|
||||||
|
return cls(client.http.client_id, *args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def url_only(**kwargs) -> str:
|
||||||
|
"""Construct a OAuth2 URL instead of an OAuth2 object."""
|
||||||
|
oauth = OAuth2(**kwargs)
|
||||||
|
return oauth.url
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scopes(self):
|
||||||
|
""":class:`frozenset` - A frozenset of the current scopes"""
|
||||||
|
return frozenset(self.__scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attributes(self):
|
||||||
|
"""Attributes used when constructing url parameters."""
|
||||||
|
data = {"client_id": self.client_id, "redirect_uri": quote(self.redirect_uri)}
|
||||||
|
|
||||||
|
if self.scopes:
|
||||||
|
data["scope"] = quote(" ".join(self.scopes))
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
data["state"] = self.state
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
attrs = attributes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> str:
|
||||||
|
""":class:`str` - The formatted url parameters that are used."""
|
||||||
|
return "&".join("{0}={1}".format(*item) for item in self.attributes.items())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
""":class:`str` - The formatted oauth url used for authorization."""
|
||||||
|
return self._BASE.format(parameters=self.parameters)
|
||||||
|
|
||||||
|
# Public api
|
||||||
|
|
||||||
|
def set_scopes(self, **scopes: Dict[str, bool]) -> None:
|
||||||
|
r"""Modify the scopes for the current oauth2 object.
|
||||||
|
|
||||||
|
Add or remove certain scopes from this oauth2 instance.
|
||||||
|
Since hypens are not allowed, replace the _ with -.
|
||||||
|
|
||||||
|
>>> oauth2.set_scopes(user_read_playback_state=True)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
\*\*scopes: Dict[:class:`str`, :class:`bool`]
|
||||||
|
The scopes to enable or disable.
|
||||||
|
"""
|
||||||
|
for scope_name, state in scopes.items():
|
||||||
|
scope_name = scope_name.replace("_","-")
|
||||||
|
if state:
|
||||||
|
self.__scopes.add(scope_name)
|
||||||
|
else:
|
||||||
|
self.__scopes.remove(scope_name)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# pylint: skip-file
|
||||||
|
|
||||||
|
from spotify import *
|
||||||
|
from spotify import __all__, _types, Client
|
||||||
|
from spotify.utils import clean as _clean_namespace
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from .models import Client, Synchronous as _Sync
|
||||||
|
|
||||||
|
|
||||||
|
with _clean_namespace(locals(), "name", "base", "Mock"):
|
||||||
|
for name, base in _types.items():
|
||||||
|
|
||||||
|
class Mock(base, metaclass=_Sync): # type: ignore
|
||||||
|
__slots__ = {"__client_thread__"}
|
||||||
|
|
||||||
|
Mock.__name__ = base.__name__
|
||||||
|
Mock.__qualname__ = base.__qualname__
|
||||||
|
Mock.__doc__ = base.__doc__
|
||||||
|
|
||||||
|
locals()[name] = Mock
|
||||||
|
setattr(models, name, Mock)
|
||||||
|
|
||||||
|
Client._default_http_client = locals()[
|
||||||
|
"HTTPClient"
|
||||||
|
] # pylint: disable=protected-access
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import getmembers, iscoroutinefunction
|
||||||
|
from typing import Type, Callable, TYPE_CHECKING
|
||||||
|
|
||||||
|
from .. import Client as _Client
|
||||||
|
from .thread import EventLoopThread
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import spotify
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_initializer(base: Type, name: str) -> Callable[..., None]:
|
||||||
|
"""Infer the __init__ to use for a given :class:`typing.Type` base and :class:`str` name."""
|
||||||
|
if name in {"HTTPClient", "HTTPUserClient"}:
|
||||||
|
|
||||||
|
def initializer(self: "spotify.HTTPClient", *args, **kwargs) -> None:
|
||||||
|
base.__init__(self, *args, **kwargs)
|
||||||
|
self.__client_thread__ = kwargs["loop"].__spotify_thread__ # type: ignore
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert name != "Client"
|
||||||
|
|
||||||
|
def initializer(self: "spotify.SpotifyBase", client: _Client, *args, **kwargs) -> None: # type: ignore
|
||||||
|
base.__init__(self, client, *args, **kwargs)
|
||||||
|
self.__client_thread__ = client.__client_thread__ # type: ignore
|
||||||
|
|
||||||
|
return initializer
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_coroutine_function(corofunc):
|
||||||
|
if isinstance(corofunc, classmethod):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@wraps(corofunc)
|
||||||
|
def wrapped(cls, client, *args, **kwargs):
|
||||||
|
assert isinstance(client, _Client)
|
||||||
|
client.__client_thread__.run_coroutine_threadsafe(
|
||||||
|
corofunc(cls, client, *args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
@wraps(corofunc)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
return self.__client_thread__.run_coroutine_threadsafe(
|
||||||
|
corofunc(self, *args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
class Synchronous(type):
|
||||||
|
"""Metaclass used for overloading coroutine functions on models."""
|
||||||
|
|
||||||
|
def __new__(cls, name, bases, dct):
|
||||||
|
klass = super().__new__(cls, name, bases, dct)
|
||||||
|
|
||||||
|
base = bases[0]
|
||||||
|
name = base.__name__
|
||||||
|
|
||||||
|
if name != "Client":
|
||||||
|
# Models and the HTTP classes get their __init__ overloaded.
|
||||||
|
initializer = _infer_initializer(base, name)
|
||||||
|
setattr(klass, "__init__", initializer)
|
||||||
|
|
||||||
|
for ident, obj in getmembers(base):
|
||||||
|
if not iscoroutinefunction(obj):
|
||||||
|
continue
|
||||||
|
|
||||||
|
setattr(klass, ident, _normalize_coroutine_function(obj))
|
||||||
|
|
||||||
|
return klass # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Client(_Client, metaclass=Synchronous):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
thread = EventLoopThread()
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
kwargs["loop"] = thread.loop # pylint: disable=protected-access
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.__thread = self.__client_thread__ = thread
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from asyncio import new_event_loop, run_coroutine_threadsafe, set_event_loop
|
||||||
|
from threading import Thread, RLock, get_ident
|
||||||
|
from typing import Any, Coroutine
|
||||||
|
|
||||||
|
|
||||||
|
class EventLoopThread(Thread):
|
||||||
|
"""A surrogate thread that spins an asyncio event loop."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
|
||||||
|
self.__lock = RLock()
|
||||||
|
self.__loop = loop = new_event_loop()
|
||||||
|
loop.__spotify_thread__ = self
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loop(self):
|
||||||
|
return self.__loop
|
||||||
|
|
||||||
|
# Overloads
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
set_event_loop(self.__loop)
|
||||||
|
self.__loop.run_forever()
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
|
||||||
|
def run_coroutine_threadsafe(self, coro: Coroutine) -> Any:
|
||||||
|
"""Like :func:`asyncio.run_coroutine_threadsafe` but for this specific thread."""
|
||||||
|
|
||||||
|
# If the current thread is the same
|
||||||
|
# as the event loop Thread.
|
||||||
|
#
|
||||||
|
# then we're in the process of making
|
||||||
|
# nested calls to await other coroutines
|
||||||
|
# and should pass back the coroutine as it should be.
|
||||||
|
if get_ident() == self.ident:
|
||||||
|
return coro
|
||||||
|
|
||||||
|
# Double lock because I haven't looked
|
||||||
|
# into whether this deadlocks under whatever
|
||||||
|
# conditions, Best to play it safe.
|
||||||
|
with self.__lock:
|
||||||
|
future = run_coroutine_threadsafe(coro, self.__loop)
|
||||||
|
|
||||||
|
return future.result()
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from re import compile as re_compile
|
||||||
|
from functools import lru_cache
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Iterable, Hashable, TypeVar, Dict, Tuple
|
||||||
|
|
||||||
|
__all__ = ("clean", "filter_items", "to_id")
|
||||||
|
|
||||||
|
_URI_RE = re_compile(r"^.*:([a-zA-Z0-9]+)$")
|
||||||
|
_OPEN_RE = re_compile(r"http[s]?:\/\/open\.spotify\.com\/(.*)\/(.*)")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def clean(mapping: dict, *keys: Iterable[Hashable]):
|
||||||
|
"""A helper context manager that defers mutating a mapping."""
|
||||||
|
yield
|
||||||
|
for key in keys:
|
||||||
|
mapping.pop(key)
|
||||||
|
|
||||||
|
|
||||||
|
K = TypeVar("K") # pylint: disable=invalid-name
|
||||||
|
V = TypeVar("V") # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_filter_items(data: Tuple[Tuple[K, V], ...]) -> Dict[K, V]:
|
||||||
|
data_ = {}
|
||||||
|
for key, value in data:
|
||||||
|
if value is not None:
|
||||||
|
data_[key] = value
|
||||||
|
return data_
|
||||||
|
|
||||||
|
|
||||||
|
def filter_items(data: Dict[K, V]) -> Dict[K, V]:
|
||||||
|
"""Filter the items of a dict where the value is not None."""
|
||||||
|
return _cached_filter_items((*data.items(),))
|
||||||
|
|
||||||
|
|
||||||
|
def to_id(value: str) -> str:
|
||||||
|
"""Get a spotify ID from a URI or open.spotify URL.
|
||||||
|
|
||||||
|
Paramters
|
||||||
|
---------
|
||||||
|
value : :class:`str`
|
||||||
|
The value to operate on.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
id : :class:`str`
|
||||||
|
The Spotify ID from the value.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
match = _URI_RE.match(value)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
match = _OPEN_RE.match(value)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
return value
|
||||||
|
return match.group(2)
|
||||||
|
return match.group(1)
|
||||||
|
|
@ -77,3 +77,5 @@ 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}>'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue