diff --git a/README.md b/README.md index 559aafd..9038bf6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Spotipy is a thin client library for the Spotify Web API. ## Documentation -Spotipy's full documentation is online at [Spotipy Documentation](http://spotipy.readthedocs.org/) +Spotipy's full documentation is online at [Spotipy Documentation](http://spotipy.readthedocs.org/) ## Installation @@ -14,7 +14,7 @@ If you already have [Python](http://www.python.org/) on your system you can inst python setup.py install -You can also install it using a popular package manager with +You can also install it using a popular package manager with `pip install spotipy` @@ -29,7 +29,7 @@ or ## Quick Start -To get started, simply install spotipy, reate a Spotify object and call methods: +To get started, simply install spotipy, create a Spotify object and call methods: import spotipy sp = spotipy.Spotify() @@ -39,7 +39,7 @@ To get started, simply install spotipy, reate a Spotify object and call methods: print ' ', i, t['name'] A full set of examples can be found in the [online documentation](http://spotipy.readthedocs.org/) and in the [Spotipy examples directory](https://github.com/plamere/spotipy/tree/master/examples). - + ## Reporting Issues @@ -64,10 +64,11 @@ If you have suggestions, bugs or other issues specific to this library, file the - v2.0.2 - August 25, 2014 -- Moved to spotipy at pypi - v2.1.0 - October 25, 2014 -- Added support for new_releases and featured_playlists - v2.2.0 - November 15, 2014 -- Added support for user_playlist_tracks -- v2.3.0 - January 5, 2015 -- Added session support added by akx. +- v2.3.0 - January 5, 2015 -- Added session support added by akx. - v2.3.2 - March 31, 2015 -- Added auto retry logic - v2.3.3 - April 1, 2015 -- added client credential flow - v2.3.5 - April 28, 2015 -- Fixed bug in auto retry logic - v2.3.6 - June 3, 2015 -- Support for offset/limit with album_tracks API - v2.3.7 - August 10, 2015 -- Added current_user_followed_artists - v2.3.8 - March 30, 2016 -- Added recs, audio features, user top lists +- v2.4.0 - December 31, 2016 -- Incorporated a number of PRs diff --git a/docs/index.rst b/docs/index.rst index 415b706..c77a4f1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -98,12 +98,32 @@ Many methods require user authentication. For these requests you will need to generate an authorization token that indicates that the user has granted permission for your application to perform the given task. You will need to register your app to get the credentials necessary to make authorized calls. + +Even if your script does not have an accessible URL you need to specify one +when registering your application where the spotify authentication API will +redirect to after successful login. The URL doesn't need to work or be +accessible, you can specify "http://localhost/", after successful login you +just need to copy the "http://localhost/?code=..." URL from your browser +and paste it to the console where your script is running. + Register your app at `My Applications `_. -*Spotipy* provides a +*spotipy* supports two authorization flows: + + - The **Authorization Code flow** This method is suitable for long-running applications + which the user logs into once. It provides an access token that can be refreshed. + + - The **Client Credentials flow** The method makes it possible + to authenticate your requests to the Spotify Web API and to obtain + a higher rate limit than you would + + +Authorization Code Flow +======================= +To support the **Authorization Code Flow** *Spotipy* provides a utility method ``util.prompt_for_user_token`` that will attempt to authorize the user. You can pass your app credentials directly into the method as arguments, or if you are reluctant to immortalize your app credentials in your source code, @@ -117,8 +137,9 @@ Call ``util.prompt_for_user_token`` method with the username and the desired scope (see `Using Scopes `_ for information about scopes) and credentials. This will coordinate the user authorization via -your web browser. The credentials are cached locally and are used to automatically -re-authorized expired tokens. +your web browser and ask for the SPOTIPY_REDIRECT_URI you were redirected to +with the authorization token appended. The credentials are cached locally and +are used to automatically re-authorized expired tokens. Here's an example of getting user authorization to read a user's saved tracks:: @@ -145,6 +166,31 @@ Here's an example of getting user authorization to read a user's saved tracks:: else: print "Can't get token for", username +Client Credentials Flow +======================= +To support the **Client Credentials Flow** *Spotipy* provides a +class SpotifyClientCredentials that can be used to authenticate requests like so:: + + + import spotipy + from spotipy.oauth2 import SpotifyClientCredentials + + client_credentials_manager = SpotifyClientCredentials() + sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) + + playlists = sp.user_playlists('spotify') + while playlists: + for i, playlist in enumerate(playlists['items']): + print("%4d %s %s" % (i + 1 + playlists['offset'], playlist['uri'], playlist['name'])) + if playlists['next']: + playlists = sp.next(playlists) + else: + playlists = None + +Client credentials flow is appropriate for requests that do not require access to a +user's private data. Even if you are only making calls that do not require +authorization, using this flow yields the benefit of a higher rate limit + IDs URIs and URLs ======================= *Spotipy* supports a number of different ID types: @@ -305,6 +351,7 @@ Spotipy authored by Paul Lamere (plamere) with contributions by: - Steve Winton // swinton - Tim Balzer // timbalzer - corycorycory // corycorycory + - Nathan Coleman // nathancoleman License ======= diff --git a/examples/add_a_saved_album.py b/examples/add_a_saved_album.py new file mode 100644 index 0000000..b98abcd --- /dev/null +++ b/examples/add_a_saved_album.py @@ -0,0 +1,27 @@ + +# Add tracks to 'Your Collection' of saved tracks + +import pprint +import sys + +import spotipy +import spotipy.util as util + +scope = 'user-library-modify' + +if len(sys.argv) > 2: + username = sys.argv[1] + aids = sys.argv[2:] +else: + print("Usage: %s username album-id ..." % (sys.argv[0],)) + sys.exit() + +token = util.prompt_for_user_token(username, scope) + +if token: + sp = spotipy.Spotify(auth=token) + sp.trace = False + results = sp.current_user_saved_albums_add(albums=aids) + pprint.pprint(results) +else: + print("Can't get token for", username) diff --git a/examples/artist_discography.py b/examples/artist_discography.py index 8d30d15..34114bb 100644 --- a/examples/artist_discography.py +++ b/examples/artist_discography.py @@ -34,7 +34,7 @@ def show_artist_albums(id): print('Total albums:', len(albums)) unique = set() # skip duplicate albums for album in albums: - name = album['name'] + name = album['name'].lower() if not name in unique: print(name) unique.add(name) diff --git a/examples/audio_analysis_for_track.py b/examples/audio_analysis_for_track.py new file mode 100644 index 0000000..39e8347 --- /dev/null +++ b/examples/audio_analysis_for_track.py @@ -0,0 +1,23 @@ +# shows audio analysis for the given track + +from __future__ import print_function # (at top of module) +from spotipy.oauth2 import SpotifyClientCredentials +import json +import spotipy +import time +import sys + + +client_credentials_manager = SpotifyClientCredentials() +sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) + +if len(sys.argv) > 1: + tid = sys.argv[1] +else: + tid = 'spotify:track:4TTV7EcfroSLWzXRY6gLv6' + +start = time.time() +analysis = sp.audio_analysis(tid) +delta = time.time() - start +print(json.dumps(analysis, indent=4)) +print ("analysis retrieved in %.2f seconds" % (delta,)) diff --git a/examples/audio_features.py b/examples/audio_features.py index d956c37..079f4f9 100644 --- a/examples/audio_features.py +++ b/examples/audio_features.py @@ -15,14 +15,22 @@ sp.trace=False if len(sys.argv) > 1: artist_name = ' '.join(sys.argv[1:]) - results = sp.search(q=artist_name, limit=50) - tids = [] - for i, t in enumerate(results['tracks']['items']): - print(' ', i, t['name']) - tids.append(t['uri']) +else: + artist_name = 'weezer' - start = time.time() - features = sp.audio_features(tids) - delta = time.time() - start - print(json.dumps(features, indent=4)) - print ("features retrieved in %.2f seconds" % (delta,)) +results = sp.search(q=artist_name, limit=50) +tids = [] +for i, t in enumerate(results['tracks']['items']): + print(' ', i, t['name']) + tids.append(t['uri']) + +start = time.time() +features = sp.audio_features(tids) +delta = time.time() - start +for feature in features: + print(json.dumps(feature, indent=4)) + print() + analysis = sp._get(feature['analysis_url']) + print(json.dumps(analysis, indent=4)) + print() +print ("features retrieved in %.2f seconds" % (delta,)) diff --git a/examples/change_playlist_details.py b/examples/change_playlist_details.py new file mode 100644 index 0000000..ac2a7bb --- /dev/null +++ b/examples/change_playlist_details.py @@ -0,0 +1,38 @@ + +# Modify the details of a playlist (name, public, collaborative) + +import sys + +import spotipy +import spotipy.util as util + +if len(sys.argv) > 3: + username = sys.argv[1] + playlist_id = sys.argv[2] + name = sys.argv[3] + + public = None + if len(sys.argv) > 4: + public = sys.argv[4].lower() == 'true' + + collaborative = None + if len(sys.argv) > 5: + collaborative = sys.argv[5].lower() == 'true' + +else: + print ("Usage: %s username playlist_id name [public collaborative]" % + (sys.argv[0])) + sys.exit() + +scope = 'playlist-modify-public playlist-modify-private' +token = util.prompt_for_user_token(username, scope) + +if token: + sp = spotipy.Spotify(auth=token) + sp.trace = False + results = sp.user_playlist_change_details( + username, playlist_id, name=name, public=public, + collaborative=collaborative) + print results +else: + print "Can't get token for", username diff --git a/examples/my_playlists.py b/examples/my_playlists.py new file mode 100644 index 0000000..58e02df --- /dev/null +++ b/examples/my_playlists.py @@ -0,0 +1,26 @@ +# Shows the top artists for a user + +import pprint +import sys + +import spotipy +import spotipy.util as util +import simplejson as json + +if len(sys.argv) > 1: + username = sys.argv[1] +else: + print("Usage: %s username" % (sys.argv[0],)) + sys.exit() + +scope = '' +token = util.prompt_for_user_token(username, scope) + +if token: + sp = spotipy.Spotify(auth=token) + sp.trace = False + results = sp.current_user_playlists(limit=50) + for i, item in enumerate(results['items']): + print("%d %s" %(i, item['name'])) +else: + print("Can't get token for", username) diff --git a/examples/show_album.py b/examples/show_album.py index 2ec7b8f..e492b3c 100644 --- a/examples/show_album.py +++ b/examples/show_album.py @@ -1,5 +1,5 @@ -# shows artist info for a URN or URL +# shows album info for a URN or URL import spotipy import sys @@ -8,10 +8,9 @@ import pprint if len(sys.argv) > 1: urn = sys.argv[1] else: - urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' + urn = 'spotify:album:5yTx83u3qerZF7GRJu7eFk' sp = spotipy.Spotify() -artist = sp.artist(urn) -pprint.pprint(artist) - +album = sp.album(urn) +pprint.pprint(album) diff --git a/examples/show_my_saved_tracks.py b/examples/show_my_saved_tracks.py index 4e74562..ac3fc42 100644 --- a/examples/show_my_saved_tracks.py +++ b/examples/show_my_saved_tracks.py @@ -1,5 +1,4 @@ - -# Adds tracks to a playlist +# shows a user's saved tracks (need to be authenticated via oauth) import sys import spotipy diff --git a/examples/test_request_timeout.py b/examples/test_request_timeout.py new file mode 100644 index 0000000..167e4f1 --- /dev/null +++ b/examples/test_request_timeout.py @@ -0,0 +1,14 @@ +# shows artist info for a URN or URL + +import spotipy +import sys +import pprint + +if len(sys.argv) > 1: + search_str = sys.argv[1] +else: + search_str = 'Radiohead' + +sp = spotipy.Spotify(requests_timeout=.1) +result = sp.search(search_str) +pprint.pprint(result) diff --git a/examples/user_playlists_contents.py b/examples/user_playlists_contents.py index b0bac03..e03ceec 100644 --- a/examples/user_playlists_contents.py +++ b/examples/user_playlists_contents.py @@ -16,7 +16,7 @@ if __name__ == '__main__': username = sys.argv[1] else: print("Whoops, need your username!") - print("usage: python user_playlists.py [username]") + print("usage: python user_playlists_contents.py [username]") sys.exit() token = util.prompt_for_user_token(username) diff --git a/examples/user_public_playlists.py b/examples/user_public_playlists.py new file mode 100644 index 0000000..3557fc3 --- /dev/null +++ b/examples/user_public_playlists.py @@ -0,0 +1,25 @@ +# Gets all the public playlists for the given +# user. Uses Client Credentials flow +# + +import sys +import spotipy +from spotipy.oauth2 import SpotifyClientCredentials + +client_credentials_manager = SpotifyClientCredentials() +sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) + +user = 'spotify' + +if len(sys.argv) > 1: + user = sys.argv[1] + +playlists = sp.user_playlists(user) + +while playlists: + for i, playlist in enumerate(playlists['items']): + print("%4d %s %s" % (i + 1 + playlists['offset'], playlist['uri'], playlist['name'])) + if playlists['next']: + playlists = sp.next(playlists) + else: + playlists = None diff --git a/setup.py b/setup.py index efcd94a..48562fd 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup setup( name='spotipy', - version='2.3.8', + version='2.4.0', description='simple client for the Spotify Web API', author="@plamere", author_email="paul@echonest.com", diff --git a/spotipy/client.py b/spotipy/client.py index d497176..4956dfb 100644 --- a/spotipy/client.py +++ b/spotipy/client.py @@ -3,7 +3,6 @@ from __future__ import print_function import sys -import base64 import requests import json import time @@ -11,16 +10,23 @@ import time ''' A simple and thin Python library for the Spotify Web API ''' + class SpotifyException(Exception): - def __init__(self, http_status, code, msg): + def __init__(self, http_status, code, msg, headers=None): self.http_status = http_status self.code = code self.msg = msg + # `headers` is used to support `Retry-After` in the event of a + # 429 status code. + if headers is None: + headers = {} + self.headers = headers def __str__(self): return 'http status: {0}, code:{1} - {2}'.format( self.http_status, self.code, self.msg) + class Spotify(object): ''' Example usage:: @@ -45,7 +51,7 @@ class Spotify(object): max_get_retries = 10 def __init__(self, auth=None, requests_session=True, - client_credentials_manager=None, proxies=None): + client_credentials_manager=None, proxies=None, requests_timeout=None): ''' Create a Spotify API object. @@ -59,12 +65,14 @@ class Spotify(object): SpotifyClientCredentials object :param proxies: Definition of proxies (optional) - + :param requests_timeout: + Tell Requests to stop waiting for a response after a given number of seconds ''' self.prefix = 'https://api.spotify.com/v1/' self._auth = auth self.client_credentials_manager = client_credentials_manager self.proxies = proxies + self.requests_timeout = requests_timeout if isinstance(requests_session, requests.Session): self._session = requests_session @@ -86,6 +94,7 @@ class Spotify(object): def _internal_call(self, method, url, payload, params): args = dict(params=params) + args["timeout"] = self.requests_timeout if not url.startswith('http'): url = self.prefix + url headers = self._auth_headers() @@ -111,10 +120,11 @@ class Spotify(object): except: if r.text and len(r.text) > 0 and r.text != 'null': raise SpotifyException(r.status_code, - -1, '%s:\n %s' % (r.url, r.json()['error']['message'])) + -1, '%s:\n %s' % (r.url, r.json()['error']['message']), + headers=r.headers) else: raise SpotifyException(r.status_code, - -1, '%s:\n %s' % (r.url, 'error')) + -1, '%s:\n %s' % (r.url, 'error'), headers=r.headers) finally: r.connection.close() if r.text and len(r.text) > 0 and r.text != 'null': @@ -142,25 +152,26 @@ class Spotify(object): if retries < 0: raise else: - print ('retrying ...' + str(delay) + 'secs') - time.sleep(delay) + sleep_seconds = int(e.headers.get('Retry-After', delay)) + print ('retrying ...' + str(sleep_seconds) + 'secs') + time.sleep(sleep_seconds) delay += 1 else: raise - except Exception as e: + except Exception as e: raise print ('exception', str(e)) # some other exception. Requests have # been know to throw a BadStatusLine exception retries -= 1 if retries >= 0: + sleep_seconds = int(e.headers.get('Retry-After', delay)) print ('retrying ...' + str(delay) + 'secs') - time.sleep(delay) + time.sleep(sleep_seconds) delay += 1 else: raise - def _post(self, url, args=None, payload=None, **kwargs): if args: kwargs.update(args) @@ -211,15 +222,16 @@ class Spotify(object): trid = self._get_id('track', track_id) return self._get('tracks/' + trid) - def tracks(self, tracks): + def tracks(self, tracks, market = None): ''' returns a list of tracks given a list of track IDs, URIs, or URLs Parameters: - tracks - a list of spotify URIs, URLs or IDs + - market - an ISO 3166-1 alpha-2 country code. ''' tlist = [self._get_id('track', t) for t in tracks] - return self._get('tracks/?ids=' + ','.join(tlist)) + return self._get('tracks/?ids=' + ','.join(tlist), market = market) def artist(self, artist_id): ''' returns a single artist given the artist's ID, URI or URL @@ -231,7 +243,6 @@ class Spotify(object): trid = self._get_id('artist', artist_id) return self._get('artists/' + trid) - def artists(self, artists): ''' returns a list of artists given the artist IDs, URIs, or URLs @@ -242,8 +253,8 @@ class Spotify(object): tlist = [self._get_id('artist', a) for a in artists] return self._get('artists/?ids=' + ','.join(tlist)) - def artist_albums(self, artist_id, album_type=None, country=None, - limit=20, offset=0): + def artist_albums(self, artist_id, album_type=None, country=None, limit=20, + offset=0): ''' Get Spotify catalog information about an artist's albums Parameters: @@ -256,7 +267,7 @@ class Spotify(object): trid = self._get_id('artist', artist_id) return self._get('artists/' + trid + '/albums', album_type=album_type, - country=country, limit=limit, offset=offset) + country=country, limit=limit, offset=offset) def artist_top_tracks(self, artist_id, country='US'): ''' Get Spotify catalog information about an artist's top 10 tracks @@ -301,7 +312,8 @@ class Spotify(object): ''' trid = self._get_id('album', album_id) - return self._get('albums/' + trid + '/tracks/', limit=limit, offset=offset) + return self._get('albums/' + trid + '/tracks/', limit=limit, + offset=offset) def albums(self, albums): ''' returns a list of albums given the album IDs, URIs, or URLs @@ -313,7 +325,7 @@ class Spotify(object): tlist = [self._get_id('album', a) for a in albums] return self._get('albums/?ids=' + ','.join(tlist)) - def search(self, q, limit=10, offset=0, type='track'): + def search(self, q, limit=10, offset=0, type='track', market=None): ''' searches for an item Parameters: @@ -322,8 +334,9 @@ class Spotify(object): - offset - the index of the first item to return - type - the type of item to return. One of 'artist', 'album', 'track' or 'playlist' + - market - An ISO 3166-1 alpha-2 country code or the string from_token. ''' - return self._get('search', q=q, limit=limit, offset=offset, type=type) + return self._get('search', q=q, limit=limit, offset=offset, type=type, market=market) def user(self, user): ''' Gets basic profile information about a Spotify User @@ -333,6 +346,14 @@ class Spotify(object): ''' return self._get('users/' + user) + def current_user_playlists(self, limit=50, offset=0): + """ Get current user playlists without required getting his profile + Parameters: + - limit - the number of items to return + - offset - the index of the first item to return + """ + return self._get("me/playlists", limit=limit, offset=offset) + def user_playlists(self, user, limit=50, offset=0): ''' Gets playlists of a user @@ -341,22 +362,23 @@ class Spotify(object): - limit - the number of items to return - offset - the index of the first item to return ''' - return self._get("users/%s/playlists" % user, limit=limit, offset=offset) + return self._get("users/%s/playlists" % user, limit=limit, + offset=offset) - def user_playlist(self, user, playlist_id = None, fields=None): + def user_playlist(self, user, playlist_id=None, fields=None): ''' Gets playlist of a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - fields - which fields to return ''' - if playlist_id == None: + if playlist_id is None: return self._get("users/%s/starred" % (user), fields=fields) plid = self._get_id('playlist', playlist_id) return self._get("users/%s/playlists/%s" % (user, plid), fields=fields) - def user_playlist_tracks(self, user, playlist_id = None, fields=None, - limit=100, offset=0, market=None): + def user_playlist_tracks(self, user, playlist_id=None, fields=None, + limit=100, offset=0, market=None): ''' Get full details of the tracks of a playlist owned by a user. Parameters: @@ -369,7 +391,8 @@ class Spotify(object): ''' plid = self._get_id('playlist', playlist_id) return self._get("users/%s/playlists/%s/tracks" % (user, plid), - limit=limit, offset=offset, fields=fields, market=market) + limit=limit, offset=offset, fields=fields, + market=market) def user_playlist_create(self, user, name, public=True): ''' Creates a playlist for a user @@ -379,11 +402,42 @@ class Spotify(object): - name - the name of the playlist - public - is the created playlist public ''' - data = {'name':name, 'public':public } - return self._post("users/%s/playlists" % (user,), payload = data) + data = {'name': name, 'public': public} + return self._post("users/%s/playlists" % (user,), payload=data) + + def user_playlist_change_details( + self, user, playlist_id, name=None, public=None, + collaborative=None): + ''' Changes a playlist's name and/or public/private state + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - name - optional name of the playlist + - public - optional is the playlist public + - collaborative - optional is the playlist collaborative + ''' + data = {} + if isinstance(name, basestring): + data['name'] = name + if isinstance(public, bool): + data['public'] = public + if isinstance(collaborative, bool): + data['collaborative'] = collaborative + return self._put("users/%s/playlists/%s" % (user, playlist_id), + payload=data) + + def user_playlist_unfollow(self, user, playlist_id): + ''' Unfollows (deletes) a playlist for a user + + Parameters: + - user - the id of the user + - name - the name of the playlist + ''' + return self._delete("users/%s/playlists/%s/followers" % (user, playlist_id)) def user_playlist_add_tracks(self, user, playlist_id, tracks, - position=None): + position=None): ''' Adds tracks to a playlist Parameters: @@ -393,9 +447,9 @@ class Spotify(object): - position - the position to add the tracks ''' plid = self._get_id('playlist', playlist_id) - ftracks = [ self._get_uri('track', tid) for tid in tracks] - return self._post("users/%s/playlists/%s/tracks" % (user,plid), - payload = ftracks, position=position) + ftracks = [self._get_uri('track', tid) for tid in tracks] + return self._post("users/%s/playlists/%s/tracks" % (user, plid), + payload=ftracks, position=position) def user_playlist_replace_tracks(self, user, playlist_id, tracks): ''' Replace all tracks in a playlist @@ -406,13 +460,14 @@ class Spotify(object): - tracks - the list of track ids to add to the playlist ''' plid = self._get_id('playlist', playlist_id) - ftracks = [ self._get_uri('track', tid) for tid in tracks] - payload = { "uris": ftracks } - return self._put("users/%s/playlists/%s/tracks" % (user,plid), - payload = payload) + ftracks = [self._get_uri('track', tid) for tid in tracks] + payload = {"uris": ftracks} + return self._put("users/%s/playlists/%s/tracks" % (user, plid), + payload=payload) - def user_playlist_reorder_tracks(self, user, playlist_id, range_start, insert_before, - range_length=1, snapshot_id=None): + def user_playlist_reorder_tracks( + self, user, playlist_id, range_start, insert_before, + range_length=1, snapshot_id=None): ''' Reorder tracks in a playlist Parameters: @@ -424,16 +479,16 @@ class Spotify(object): - snapshot_id - optional playlist's snapshot ID ''' plid = self._get_id('playlist', playlist_id) - payload = { "range_start": range_start, - "range_length": range_length, - "insert_before": insert_before } + payload = {"range_start": range_start, + "range_length": range_length, + "insert_before": insert_before} if snapshot_id: payload["snapshot_id"] = snapshot_id - return self._put("users/%s/playlists/%s/tracks" % (user,plid), - payload = payload) + return self._put("users/%s/playlists/%s/tracks" % (user, plid), + payload=payload) - def user_playlist_remove_all_occurrences_of_tracks(self, user, playlist_id, - tracks, snapshot_id=None): + def user_playlist_remove_all_occurrences_of_tracks( + self, user, playlist_id, tracks, snapshot_id=None): ''' Removes all occurrences of the given tracks from the given playlist Parameters: @@ -445,15 +500,15 @@ class Spotify(object): ''' plid = self._get_id('playlist', playlist_id) - ftracks = [ self._get_uri('track', tid) for tid in tracks] - payload = { "tracks": [ {"uri": track} for track in ftracks] } + ftracks = [self._get_uri('track', tid) for tid in tracks] + payload = {"tracks": [{"uri": track} for track in ftracks]} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete("users/%s/playlists/%s/tracks" % (user, plid), - payload = payload) + payload=payload) - def user_playlist_remove_specific_occurrences_of_tracks(self, user, - playlist_id, tracks, snapshot_id=None): + def user_playlist_remove_specific_occurrences_of_tracks( + self, user, playlist_id, tracks, snapshot_id=None): ''' Removes all occurrences of the given tracks from the given playlist Parameters: @@ -472,11 +527,34 @@ class Spotify(object): "uri": self._get_uri("track", tr["uri"]), "positions": tr["positions"], }) - payload = { "tracks": ftracks } + payload = {"tracks": ftracks} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete("users/%s/playlists/%s/tracks" % (user, plid), - payload = payload) + payload=payload) + + def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id): + ''' + Add the current authenticated user as a follower of a playlist. + + Parameters: + - playlist_owner_id - the user id of the playlist owner + - playlist_id - the id of the playlist + + ''' + return self._put("users/{}/playlists/{}/followers".format(playlist_owner_id, playlist_id)) + + def user_playlist_is_following(self, playlist_owner_id, playlist_id, user_ids): + ''' + Check to see if the given users are following the given playlist + + Parameters: + - playlist_owner_id - the user id of the playlist owner + - playlist_id - the id of the playlist + - user_ids - the ids of the users that you want to check to see if they follow the playlist. Maximum: 5 ids. + + ''' + return self._get("users/{}/playlists/{}/followers/contains?ids={}".format(playlist_owner_id, playlist_id, ','.join(user_ids))) def me(self): ''' Get detailed profile information about the current user. @@ -511,7 +589,7 @@ class Spotify(object): ''' return self._get('me/tracks', limit=limit, offset=offset) - + def current_user_followed_artists(self, limit=20, after=None): ''' Gets a list of the artists followed by the current authorized user @@ -520,64 +598,83 @@ class Spotify(object): - after - ghe last artist ID retrieved from the previous request ''' - return self._get('me/following', type='artist', limit=limit, after=after) + return self._get('me/following', type='artist', limit=limit, + after=after) - def current_user_saved_tracks_delete(self, tracks=[]): + def current_user_saved_tracks_delete(self, tracks=None): ''' Remove one or more tracks from the current user's "Your Music" library. Parameters: - tracks - a list of track URIs, URLs or IDs ''' - tlist = [self._get_id('track', t) for t in tracks] + tlist = [] + if tracks is not None: + tlist = [self._get_id('track', t) for t in tracks] return self._delete('me/tracks/?ids=' + ','.join(tlist)) - def current_user_saved_tracks_contains(self, tracks=[]): + def current_user_saved_tracks_contains(self, tracks=None): ''' Check if one or more tracks is already saved in the current Spotify user’s “Your Music” library. Parameters: - tracks - a list of track URIs, URLs or IDs ''' - tlist = [self._get_id('track', t) for t in tracks] + tlist = [] + if tracks is not None: + tlist = [self._get_id('track', t) for t in tracks] return self._get('me/tracks/contains?ids=' + ','.join(tlist)) - - def current_user_saved_tracks_add(self, tracks=[]): + def current_user_saved_tracks_add(self, tracks=None): ''' Add one or more tracks to the current user's "Your Music" library. Parameters: - tracks - a list of track URIs, URLs or IDs ''' - tlist = [self._get_id('track', t) for t in tracks] + tlist = [] + if tracks is not None: + tlist = [self._get_id('track', t) for t in tracks] return self._put('me/tracks/?ids=' + ','.join(tlist)) - def current_user_top_artists(self, limit=20, offset=0, time_range='medium_term'): + def current_user_top_artists(self, limit=20, offset=0, + time_range='medium_term'): ''' Get the current user's top artists Parameters: - limit - the number of entities to return - offset - the index of the first entity to return - - time_range - Over what time frame are the affinities computed. + - time_range - Over what time frame are the affinities computed Valid-values: short_term, medium_term, long_term ''' - return self._get('me/top/artists', time_range=time_range, limit=limit,offset=offset) + return self._get('me/top/artists', time_range=time_range, limit=limit, + offset=offset) - def current_user_top_tracks(self, limit=20, offset=0, time_range='medium_term'): + def current_user_top_tracks(self, limit=20, offset=0, + time_range='medium_term'): ''' Get the current user's top tracks Parameters: - limit - the number of entities to return - offset - the index of the first entity to return - - time_range - Over what time frame are the affinities computed. + - time_range - Over what time frame are the affinities computed Valid-values: short_term, medium_term, long_term ''' - return self._get('me/top/tracks', time_range=time_range, limit=limit,offset=offset) + return self._get('me/top/tracks', time_range=time_range, limit=limit, + offset=offset) + def current_user_saved_albums_add(self, albums=[]): + ''' Add one or more albums to the current user's + "Your Music" library. + Parameters: + - albums - a list of album URIs, URLs or IDs + ''' + alist = [self._get_id('album', a) for a in albums] + r = self._put('me/albums?ids=' + ','.join(alist)) + return r - def featured_playlists(self, locale=None, country=None, - timestamp=None, limit=20, offset = 0): + def featured_playlists(self, locale=None, country=None, timestamp=None, + limit=20, offset=0): ''' Get a list of Spotify featured playlists Parameters: @@ -600,9 +697,10 @@ class Spotify(object): items. ''' return self._get('browse/featured-playlists', locale=locale, - country=country, timestamp=timestamp, limit=limit, offset=offset) + country=country, timestamp=timestamp, limit=limit, + offset=offset) - def new_releases(self, country=None, limit=20, offset = 0): + def new_releases(self, country=None, limit=20, offset=0): ''' Get a list of new album releases featured in Spotify Parameters: @@ -615,10 +713,10 @@ class Spotify(object): (the first object). Use with limit to get the next set of items. ''' - return self._get('browse/new-releases', country=country, - limit=limit, offset=offset) + return self._get('browse/new-releases', country=country, limit=limit, + offset=offset) - def categories(self, country=None, locale=None, limit=20, offset = 0): + def categories(self, country=None, locale=None, limit=20, offset=0): ''' Get a list of new album releases featured in Spotify Parameters: @@ -635,9 +733,10 @@ class Spotify(object): items. ''' return self._get('browse/categories', country=country, locale=locale, - limit=limit, offset=offset) + limit=limit, offset=offset) - def category_playlists(self, category_id=None, country=None, limit=20, offset = 0): + def category_playlists(self, category_id=None, country=None, limit=20, + offset=0): ''' Get a list of new album releases featured in Spotify Parameters: @@ -652,11 +751,11 @@ class Spotify(object): (the first object). Use with limit to get the next set of items. ''' - return self._get('browse/categories/' + category_id + '/playlists', country=country, - limit=limit, offset=offset) + return self._get('browse/categories/' + category_id + '/playlists', + country=country, limit=limit, offset=offset) - def recommendations(self, seed_artists=[], seed_genres=[], seed_tracks=[], - limit = 20, country=None, **kwargs): + def recommendations(self, seed_artists=None, seed_genres=None, + seed_tracks=None, limit=20, country=None, **kwargs): ''' Get a list of recommended tracks for one to five seeds. Parameters: @@ -664,50 +763,61 @@ class Spotify(object): - seed_tracks - a list of artist IDs, URIs or URLs - - seed_genres - a list of genre names. Available genres for + - seed_genres - a list of genre names. Available genres for recommendations can be found by calling recommendation_genre_seeds - - country - An ISO 3166-1 alpha-2 country code. If provided, all + - country - An ISO 3166-1 alpha-2 country code. If provided, all results will be playable in this country. - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 100 - - min/max/target_ - For the tuneable track attributes listed + - min/max/target_ - For the tuneable track attributes listed in the documentation, these values provide filters and targeting on results. ''' params = dict(limit=limit) if seed_artists: - params['seed_artists'] = [self._get_id('artist', a) for a in seed_artists] + params['seed_artists'] = ','.join( + [self._get_id('artist', a) for a in seed_artists]) if seed_genres: - params['seed_genres'] = seed_genres + params['seed_genres'] = ','.join(seed_genres) if seed_tracks: - params['seed_tracks'] = [self._get_id('track', t) for t in seed_tracks] + params['seed_tracks'] = ','.join( + [self._get_id('track', t) for t in seed_tracks]) if country: params['market'] = country - for attribute in ["acousticness", "danceability", "duration_ms", "energy", - "instrumentalness", "key", "liveness", "loudness", "mode", "popularity", - "speechiness", "tempo", "time_signature", "valence"]: + for attribute in ["acousticness", "danceability", "duration_ms", + "energy", "instrumentalness", "key", "liveness", + "loudness", "mode", "popularity", "speechiness", + "tempo", "time_signature", "valence"]: for prefix in ["min_", "max_", "target_"]: param = prefix + attribute if param in kwargs: params[param] = kwargs[param] - return self._get('recommendations', **params) + return self._get('recommendations', **params) def recommendation_genre_seeds(self): - ''' Get a list of genres available for the recommendations function. + ''' Get a list of genres available for the recommendations function. ''' return self._get('recommendations/available-genre-seeds') + def audio_analysis(self, track_id): + ''' Get audio analysis for a track based upon its Spotify ID + Parameters: + - track_id - a track URI, URL or ID + ''' + trid = self._get_id('track', track_id) + return self._get('audio-analysis/' + trid) + def audio_features(self, tracks=[]): ''' Get audio features for multiple tracks based upon their Spotify IDs Parameters: - tracks - a list of track URIs, URLs or IDs, maximum: 50 ids ''' tlist = [self._get_id('track', t) for t in tracks] - results = self._get('audio-features?ids=' + ','.join(tlist)) + results = self._get('audio-features?ids=' + ','.join(tlist)) # the response has changed, look for the new style first, and if # its not there, fallback on the old style if 'audio_features' in results: @@ -715,19 +825,27 @@ class Spotify(object): else: return results + def audio_analysis(self, id): + ''' Get audio analysis for a track based upon its Spotify ID + Parameters: + - id - a track URIs, URLs or IDs + ''' + id = self._get_id('track', id) + return self._get('audio-analysis/'+id) + def _get_id(self, type, id): fields = id.split(':') if len(fields) >= 3: if type != fields[-2]: - self._warn('expected id of type ' + type + ' but found type ' \ - + fields[2] + " " + id) + self._warn('expected id of type %s but found type %s %s', + type, fields[-2], id) return fields[-1] fields = id.split('/') if len(fields) >= 3: itype = fields[-2] if type != itype: - self._warn('expected id of type ' + type + ' but found type ' \ - + itype + " " + id) + self._warn('expected id of type %s but found type %s %s', + type, itype, id) return fields[-1] return id diff --git a/spotipy/oauth2.py b/spotipy/oauth2.py index cc52e63..7303ff0 100644 --- a/spotipy/oauth2.py +++ b/spotipy/oauth2.py @@ -79,7 +79,7 @@ class SpotifyClientCredentials(object): def _is_token_expired(self, token_info): now = int(time.time()) - return token_info['expires_at'] < now + return token_info['expires_at'] - now < 60 def _add_custom_values_to_token_info(self, token_info): """ @@ -131,11 +131,11 @@ class SpotifyOAuth(object): token_info = json.loads(token_info_string) # if scopes don't match, then bail - if 'scope' not in token_info or self.scope != token_info['scope']: + if 'scope' not in token_info or not self._is_scope_subset(self.scope, token_info['scope']): return None if self._is_token_expired(token_info): - token_info = self._refresh_access_token(token_info['refresh_token']) + token_info = self.refresh_access_token(token_info['refresh_token']) except IOError: pass @@ -151,6 +151,11 @@ class SpotifyOAuth(object): self._warn("couldn't write token cache to " + self.cache_path) pass + def _is_scope_subset(self, needle_scope, haystack_scope): + needle_scope = set(needle_scope.split()) + haystack_scope = set(haystack_scope.split()) + + return needle_scope <= haystack_scope def _is_token_expired(self, token_info): now = int(time.time()) @@ -222,7 +227,7 @@ class SpotifyOAuth(object): else: return None - def _refresh_access_token(self, refresh_token): + def refresh_access_token(self, refresh_token): payload = { 'refresh_token': refresh_token, 'grant_type': 'refresh_token'} diff --git a/spotipy/util.py b/spotipy/util.py index e3422f4..102c462 100644 --- a/spotipy/util.py +++ b/spotipy/util.py @@ -3,9 +3,9 @@ from __future__ import print_function import os -import subprocess from . import oauth2 import spotipy +import webbrowser def prompt_for_user_token(username, scope=None, client_id = None, client_secret = None, redirect_uri = None): @@ -67,8 +67,8 @@ def prompt_for_user_token(username, scope=None, client_id = None, ''') auth_url = sp_oauth.get_authorize_url() try: - subprocess.call(["open", auth_url]) - print("Opening %s in your browser" % auth_url) + webbrowser.open(auth_url) + print("Opened %s in your browser" % auth_url) except: print("Please navigate here: %s" % auth_url) diff --git a/tests/authtests.py b/tests/authtests.py index 540db5f..13504a6 100644 --- a/tests/authtests.py +++ b/tests/authtests.py @@ -86,6 +86,24 @@ class AuthTestSpotipy(unittest.TestCase): albums = spotify.current_user_saved_albums() self.assertTrue(len(albums['items']) > 0) + def test_current_user_playlists(self): + playlists = spotify.current_user_playlists(limit=10) + self.assertTrue('items' in playlists) + self.assertTrue(len(playlists['items']) == 10) + + def test_user_playlist_follow(self): + spotify.user_playlist_follow_playlist('plamere', '4erXB04MxwRAVqcUEpu30O') + follows = spotify.user_playlist_is_following('plamere', '4erXB04MxwRAVqcUEpu30O', ['plamere']) + + self.assertTrue(len(follows) == 1, 'proper follows length') + self.assertTrue(follows[0], 'is following') + spotify.user_playlist_unfollow('plamere', '4erXB04MxwRAVqcUEpu30O') + + follows = spotify.user_playlist_is_following('plamere', '4erXB04MxwRAVqcUEpu30O', ['plamere']) + self.assertTrue(len(follows) == 1, 'proper follows length') + self.assertFalse(follows[0], 'is no longer following') + + def test_current_user_save_and_unsave_tracks(self): tracks = spotify.current_user_saved_tracks() total = tracks['total'] diff --git a/tests/authtests2.py b/tests/authtests2.py index f0ebcd7..670e1c8 100644 --- a/tests/authtests2.py +++ b/tests/authtests2.py @@ -37,6 +37,10 @@ class AuthTestSpotipy(unittest.TestCase): bad_id = 'BAD_ID' + def test_audio_analysis(self): + result = spotify.audio_analysis(self.four_tracks[0]) + assert('beats' in result) + def test_audio_features(self): results = spotify.audio_features(self.four_tracks) self.assertTrue(len(results) == len(self.four_tracks)) diff --git a/tests/tests.py b/tests/tests.py index c78233d..760b91c 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2,6 +2,7 @@ import spotipy import unittest import pprint +import requests from spotipy.client import SpotifyException @@ -11,6 +12,7 @@ class TestSpotipy(unittest.TestCase): creep_id = '3HfB5hBU0dmBt8T0iCmH42' creep_url = 'http://open.spotify.com/track/3HfB5hBU0dmBt8T0iCmH42' el_scorcho_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQJ' + el_scorcho_bad_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQK' pinkerton_urn = 'spotify:album:04xe676vyiTeYNXw15o9jT' weezer_urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' pablo_honey_urn = 'spotify:album:6AZv3m27uyRxi8KyJSfUxL' @@ -39,7 +41,7 @@ class TestSpotipy(unittest.TestCase): def test_album_tracks(self): results = self.spotify.album_tracks(self.pinkerton_urn) self.assertTrue(len(results['items']) == 10) - + def test_album_tracks_many(self): results = self.spotify.album_tracks(self.angeles_haydn_urn) tracks = results['items'] @@ -68,6 +70,13 @@ class TestSpotipy(unittest.TestCase): track = self.spotify.track(self.creep_url) self.assertTrue(track['name'] == 'Creep') + def test_track_bad_urn(self): + try: + track = self.spotify.track(self.el_scorcho_bad_urn) + self.assertTrue(False) + except spotipy.SpotifyException: + self.assertTrue(True) + def test_tracks(self): results = self.spotify.tracks([self.creep_url, self.el_scorcho_urn]) self.assertTrue('tracks' in results) @@ -83,7 +92,7 @@ class TestSpotipy(unittest.TestCase): self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 20) for artist in results['artists']: - if artist['name'] == 'Rivers Cuomo': + if artist['name'] == 'Jimmy Eat World': found = True self.assertTrue(found) @@ -93,6 +102,12 @@ class TestSpotipy(unittest.TestCase): self.assertTrue(len(results['artists']['items']) > 0) self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') + def test_artist_search_with_market(self): + results = self.spotify.search(q='weezer', type='artist', market='GB') + self.assertTrue('artists' in results) + self.assertTrue(len(results['artists']['items']) > 0) + self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') + def test_artist_albums(self): results = self.spotify.artist_albums(self.weezer_urn) self.assertTrue('items' in results) @@ -105,6 +120,15 @@ class TestSpotipy(unittest.TestCase): self.assertTrue(found) + def test_search_timeout(self): + sp = spotipy.Spotify(requests_timeout=.1) + try: + results = sp.search(q='my*', type='track') + self.assertTrue(False, 'unexpected search timeout') + except requests.ReadTimeout: + self.assertTrue(True, 'expected search timeout') + + def test_album_search(self): results = self.spotify.search(q='weezer pinkerton', type='album') self.assertTrue('albums' in results) @@ -128,6 +152,13 @@ class TestSpotipy(unittest.TestCase): except spotipy.SpotifyException: self.assertTrue(True) + def test_track_bad_id(self): + try: + track = self.spotify.track(self.bad_id) + self.assertTrue(False) + except spotipy.SpotifyException: + self.assertTrue(True) + def test_unauthenticated_post_fails(self): with self.assertRaises(SpotifyException) as cm: self.spotify.user_playlist_create("spotify", "Best hits of the 90s")