Added spotify track queueing support
This commit is contained in:
parent
34dcc1ec10
commit
eb7c529c14
|
|
@ -42,3 +42,15 @@ class TrackLoadError(PomiceException):
|
|||
class FilterInvalidArgument(PomiceException):
|
||||
"""An invalid argument was passed to a filter."""
|
||||
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 discord
|
||||
import asyncio
|
||||
|
|
@ -5,18 +6,23 @@ import typing
|
|||
import json
|
||||
import socket
|
||||
import time
|
||||
import re
|
||||
|
||||
from discord.ext import commands
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import quote
|
||||
from . import spotify
|
||||
from . import events
|
||||
from . import exceptions
|
||||
from . import objects
|
||||
from . import __version__
|
||||
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:
|
||||
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._host = host
|
||||
self._port = port
|
||||
|
|
@ -43,6 +49,13 @@ class Node:
|
|||
|
||||
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")
|
||||
|
||||
def __repr__(self):
|
||||
|
|
@ -178,6 +191,59 @@ class Node:
|
|||
|
||||
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:
|
||||
data = await response.json()
|
||||
|
||||
|
|
@ -193,13 +259,9 @@ class Node:
|
|||
return None
|
||||
|
||||
elif load_type == "PLAYLIST_LOADED":
|
||||
if 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":
|
||||
if ctx:
|
||||
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.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]
|
||||
else:
|
||||
self.tracks = [Track(track_id=track["track"], info=track["info"]) for track in self.tracks_raw]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ class Player(VoiceProtocol):
|
|||
await self._node.send(op='destroy', guildId=str(self._guild.id))
|
||||
|
||||
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)
|
||||
self._current = track
|
||||
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:
|
||||
return f'<Pomice.NodeStats total_players={self.players_total} playing_active={self.players_active}>'
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue