From 204a1453c483acfea2caf9f54f4a4a89a9a74530 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 18:06:17 -0800 Subject: [PATCH 01/34] Update spotify.py --- beetsplug/spotify.py | 310 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 251 insertions(+), 59 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 36231f297..f34e8bf59 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -2,55 +2,257 @@ from __future__ import division, absolute_import, print_function +import os import re +import json +import base64 import webbrowser + import requests -from beets.plugins import BeetsPlugin -from beets.ui import decargs + from beets import ui -from requests.exceptions import HTTPError +from beets.plugins import BeetsPlugin +from beets.util import confit +from beets.autotag.hooks import AlbumInfo, TrackInfo class SpotifyPlugin(BeetsPlugin): - # URL for the Web API of Spotify # Documentation here: https://developer.spotify.com/web-api/search-item/ - base_url = "https://api.spotify.com/v1/search" - open_url = "http://open.spotify.com/track/" - playlist_partial = "spotify:trackset:Playlist:" + oauth_token_url = 'https://accounts.spotify.com/api/token' + base_url = 'https://api.spotify.com/v1/search' + open_url = 'http://open.spotify.com/track/' + album_url = 'https://api.spotify.com/v1/albums/' + track_url = 'https://api.spotify.com/v1/tracks/' + playlist_partial = 'spotify:trackset:Playlist:' + id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})' def __init__(self): super(SpotifyPlugin, self).__init__() - self.config.add({ - 'mode': 'list', - 'tiebreak': 'popularity', - 'show_failures': False, - 'artist_field': 'albumartist', - 'album_field': 'album', - 'track_field': 'title', - 'region_filter': None, - 'regex': [] - }) + self.config.add( + { + 'mode': 'list', + 'tiebreak': 'popularity', + 'show_failures': False, + 'artist_field': 'albumartist', + 'album_field': 'album', + 'track_field': 'title', + 'region_filter': None, + 'regex': [], + 'client_id': 'N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN', + 'client_secret': '6DRS7k66h4643yQEbepPxOuxeVW0yZpk', + 'tokenfile': 'spotify_token.json', + 'source_weight': 0.5, + 'user_token': '', + } + ) + self.config['client_secret'].redact = True + + """Path to the JSON file for storing the OAuth access token.""" + self.tokenfile = self.config['tokenfile'].get(confit.Filename(in_app_dir=True)) + self.register_listener('import_begin', self.setup) + + def setup(self): + """Retrieve previously saved OAuth token or generate a new one""" + try: + with open(self.tokenfile) as f: + token_data = json.load(f) + except IOError: + self.authenticate() + else: + self.access_token = token_data['access_token'] + + def authenticate(self): + headers = { + 'Authorization': 'Basic {}'.format( + base64.b64encode( + '{}:{}'.format( + self.config['client_id'].as_str(), + self.config['client_secret'].as_str(), + ) + ) + ) + } + response = requests.post( + self.oauth_token_url, + data={'grant_type': 'client_credentials'}, + headers=headers, + ) + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ui.UserError( + u'Spotify authorization failed: {}\n{}'.format(e, response.content) + ) + self.access_token = response.json()['access_token'] + + # Save the token for later use. + self._log.debug(u'Spotify access token: {}', self.access_token) + with open(self.tokenfile, 'w') as f: + json.dump({'access_token': self.access_token}, f) + + @property + def auth_header(self): + if not hasattr(self, 'access_token'): + self.setup() + return {'Authorization': 'Bearer {}'.format(self.access_token)} + + def _handle_response(self, request_type, url, params=None): + response = request_type(url, headers=self.auth_header, params=params) + if response.status_code != 200: + if u'token expired' in response.text: + self._log.debug('Spotify access token has expired. Reauthenticating.') + self.authenticate() + self._handle_response(request_type, url, params=params) + else: + raise ui.UserError(u'Spotify API error:\n{}', response.text) + return response + + def album_for_id(self, album_id): + """ + Fetches an album by its Spotify album ID or URL and returns an AlbumInfo object + or None if the album is not found. + """ + self._log.debug(u'Searching for album {}', album_id) + match = re.search(self.id_regex.format('album'), album_id) + if not match: + return None + spotify_album_id = match.group(2) + + response = self._handle_response( + requests.get, self.album_url + spotify_album_id + ) + + data = response.json() + + artist, artist_id = self._get_artist(data['artists']) + + date_parts = [int(part) for part in data['release_date'].split('-')] + + if data['release_date_precision'] == 'day': + year, month, day = date_parts + elif data['release_date_precision'] == 'month': + year, month = date_parts + day = None + elif data['release_date_precision'] == 'year': + year = date_parts + month = None + day = None + + album = AlbumInfo( + album=data['name'], + album_id=album_id, + artist=artist, + artist_id=artist_id, + tracks=None, + asin=None, + albumtype=data['album_type'], + va=False, + year=year, + month=month, + day=day, + label=None, + mediums=None, + artist_sort=None, + releasegroup_id=None, + catalognum=None, + script=None, + language=None, + country=None, + albumstatus=None, + media=None, + albumdisambig=None, + releasegroupdisambig=None, + artist_credit=None, + original_year=None, + original_month=None, + original_day=None, + data_source='Spotify', + data_url=None, + ) + + return album + + def track_for_id(self, track_id): + """ + Fetches a track by its Spotify track ID or URL and returns a TrackInfo object + or None if the track is not found. + """ + self._log.debug(u'Searching for track {}', track_id) + match = re.search(self.id_regex.format('track'), track_id) + if not match: + return None + spotify_track_id = match.group(2) + + response = self._handle_response( + requests.get, self.track_url + spotify_track_id + ) + data = response.json() + artist, artist_id = self._get_artist(data['artists']) + track = TrackInfo( + title=data['title'], + track_id=spotify_track_id, + release_track_id=data.get('album').get('id'), + artist=artist, + artist_id=artist_id, + length=data['duration_ms'] / 1000, + index=None, + medium=None, + medium_index=data['track_number'], + medium_total=None, + artist_sort=None, + disctitle=None, + artist_credit=None, + data_source=None, + data_url=None, + media=None, + lyricist=None, + composer=None, + composer_sort=None, + arranger=None, + track_alt=None, + ) + return track + + def _get_artist(self, artists): + """ + Returns an artist string (all artists) and an artist_id (the main + artist) for a list of Beatport release or track artists. + """ + artist_id = None + artist_names = [] + for artist in artists: + if not artist_id: + artist_id = artist['id'] + name = artist['name'] + # Strip disambiguation number. + name = re.sub(r' \(\d+\)$', '', name) + # Move articles to the front. + name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) + artist_names.append(name) + artist = ', '.join(artist_names).replace(' ,', ',') or None + return artist, artist_id def commands(self): def queries(lib, opts, args): success = self.parse_opts(opts) if success: - results = self.query_spotify(lib, decargs(args)) + results = self.query_spotify(lib, ui.decargs(args)) self.output_results(results) - spotify_cmd = ui.Subcommand( - 'spotify', - help=u'build a Spotify playlist' + + spotify_cmd = ui.Subcommand('spotify', help=u'build a Spotify playlist') + spotify_cmd.parser.add_option( + u'-m', + u'--mode', + action='store', + help=u'"open" to open Spotify with playlist, ' u'"list" to print (default)', ) spotify_cmd.parser.add_option( - u'-m', u'--mode', action='store', - help=u'"open" to open Spotify with playlist, ' - u'"list" to print (default)' - ) - spotify_cmd.parser.add_option( - u'-f', u'--show-failures', - action='store_true', dest='show_failures', - help=u'list tracks that did not match a Spotify ID' + u'-f', + u'--show-failures', + action='store_true', + dest='show_failures', + help=u'list tracks that did not match a Spotify ID', ) spotify_cmd.func = queries return [spotify_cmd] @@ -63,23 +265,20 @@ class SpotifyPlugin(BeetsPlugin): self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: - self._log.warning(u'{0} is not a valid mode', - self.config['mode'].get()) + self._log.warning(u'{0} is not a valid mode', self.config['mode'].get()) return False self.opts = opts return True def query_spotify(self, lib, query): - results = [] failures = [] items = lib.items(query) if not items: - self._log.debug(u'Your beets query returned no items, ' - u'skipping spotify') + self._log.debug(u'Your beets query returned no items, ' u'skipping spotify') return self._log.info(u'Processing {0} tracks...', len(items)) @@ -88,17 +287,11 @@ class SpotifyPlugin(BeetsPlugin): # Apply regex transformations if provided for regex in self.config['regex'].get(): - if ( - not regex['field'] or - not regex['search'] or - not regex['replace'] - ): + if not regex['field'] or not regex['search'] or not regex['replace']: continue value = item[regex['field']] - item[regex['field']] = re.sub( - regex['search'], regex['replace'], value - ) + item[regex['field']] = re.sub(regex['search'], regex['replace'], value) # Custom values can be passed in the config (just in case) artist = item[self.config['artist_field'].get()] @@ -107,15 +300,14 @@ class SpotifyPlugin(BeetsPlugin): search_url = query + " album:" + album + " artist:" + artist # Query the Web API for each track, look for the items' JSON data - r = requests.get(self.base_url, params={ - "q": search_url, "type": "track" - }) + r = self._handle_response( + requests.get, self.base_url, params={"q": search_url, "type": "track"} + ) self._log.debug('{}', r.url) try: r.raise_for_status() - except HTTPError as e: - self._log.debug(u'URL returned a {0} error', - e.response.status_code) + except requests.exceptions.HTTPError as e: + self._log.debug(u'URL returned a {0} error', e.response.status_code) failures.append(search_url) continue @@ -124,19 +316,16 @@ class SpotifyPlugin(BeetsPlugin): # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: - r_data = [x for x in r_data if region_filter - in x['available_markets']] + r_data = [x for x in r_data if region_filter in x['available_markets']] # Simplest, take the first result chosen_result = None if len(r_data) == 1 or self.config['tiebreak'].get() == "first": - self._log.debug(u'Spotify track(s) found, count: {0}', - len(r_data)) + self._log.debug(u'Spotify track(s) found, count: {0}', len(r_data)) chosen_result = r_data[0] elif len(r_data) > 1: # Use the popularity filter - self._log.debug(u'Most popular track chosen, count: {0}', - len(r_data)) + self._log.debug(u'Most popular track chosen, count: {0}', len(r_data)) chosen_result = max(r_data, key=lambda x: x['popularity']) if chosen_result: @@ -148,15 +337,18 @@ class SpotifyPlugin(BeetsPlugin): failure_count = len(failures) if failure_count > 0: if self.config['show_failures'].get(): - self._log.info(u'{0} track(s) did not match a Spotify ID:', - failure_count) + self._log.info( + u'{0} track(s) did not match a Spotify ID:', failure_count + ) for track in failures: self._log.info(u'track: {0}', track) self._log.info(u'') else: - self._log.warning(u'{0} track(s) did not match a Spotify ID;\n' - u'use --show-failures to display', - failure_count) + self._log.warning( + u'{0} track(s) did not match a Spotify ID;\n' + u'use --show-failures to display', + failure_count, + ) return results From 82319734cb310e227a213d110a8a72cebebc858c Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 18:32:41 -0800 Subject: [PATCH 02/34] `black -S -l 79` autoformat --- beetsplug/spotify.py | 59 +++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index f34e8bf59..2545813ac 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -49,7 +49,9 @@ class SpotifyPlugin(BeetsPlugin): self.config['client_secret'].redact = True """Path to the JSON file for storing the OAuth access token.""" - self.tokenfile = self.config['tokenfile'].get(confit.Filename(in_app_dir=True)) + self.tokenfile = self.config['tokenfile'].get( + confit.Filename(in_app_dir=True) + ) self.register_listener('import_begin', self.setup) def setup(self): @@ -82,7 +84,9 @@ class SpotifyPlugin(BeetsPlugin): response.raise_for_status() except requests.exceptions.HTTPError as e: raise ui.UserError( - u'Spotify authorization failed: {}\n{}'.format(e, response.content) + u'Spotify authorization failed: {}\n{}'.format( + e, response.content + ) ) self.access_token = response.json()['access_token'] @@ -101,7 +105,9 @@ class SpotifyPlugin(BeetsPlugin): response = request_type(url, headers=self.auth_header, params=params) if response.status_code != 200: if u'token expired' in response.text: - self._log.debug('Spotify access token has expired. Reauthenticating.') + self._log.debug( + 'Spotify access token has expired. Reauthenticating.' + ) self.authenticate() self._handle_response(request_type, url, params=params) else: @@ -240,12 +246,15 @@ class SpotifyPlugin(BeetsPlugin): results = self.query_spotify(lib, ui.decargs(args)) self.output_results(results) - spotify_cmd = ui.Subcommand('spotify', help=u'build a Spotify playlist') + spotify_cmd = ui.Subcommand( + 'spotify', help=u'build a Spotify playlist' + ) spotify_cmd.parser.add_option( u'-m', u'--mode', action='store', - help=u'"open" to open Spotify with playlist, ' u'"list" to print (default)', + help=u'"open" to open Spotify with playlist, ' + u'"list" to print (default)', ) spotify_cmd.parser.add_option( u'-f', @@ -265,7 +274,9 @@ class SpotifyPlugin(BeetsPlugin): self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: - self._log.warning(u'{0} is not a valid mode', self.config['mode'].get()) + self._log.warning( + u'{0} is not a valid mode', self.config['mode'].get() + ) return False self.opts = opts @@ -278,7 +289,9 @@ class SpotifyPlugin(BeetsPlugin): items = lib.items(query) if not items: - self._log.debug(u'Your beets query returned no items, ' u'skipping spotify') + self._log.debug( + u'Your beets query returned no items, ' u'skipping spotify' + ) return self._log.info(u'Processing {0} tracks...', len(items)) @@ -287,11 +300,17 @@ class SpotifyPlugin(BeetsPlugin): # Apply regex transformations if provided for regex in self.config['regex'].get(): - if not regex['field'] or not regex['search'] or not regex['replace']: + if ( + not regex['field'] + or not regex['search'] + or not regex['replace'] + ): continue value = item[regex['field']] - item[regex['field']] = re.sub(regex['search'], regex['replace'], value) + item[regex['field']] = re.sub( + regex['search'], regex['replace'], value + ) # Custom values can be passed in the config (just in case) artist = item[self.config['artist_field'].get()] @@ -301,13 +320,17 @@ class SpotifyPlugin(BeetsPlugin): # Query the Web API for each track, look for the items' JSON data r = self._handle_response( - requests.get, self.base_url, params={"q": search_url, "type": "track"} + requests.get, + self.base_url, + params={"q": search_url, "type": "track"}, ) self._log.debug('{}', r.url) try: r.raise_for_status() except requests.exceptions.HTTPError as e: - self._log.debug(u'URL returned a {0} error', e.response.status_code) + self._log.debug( + u'URL returned a {0} error', e.response.status_code + ) failures.append(search_url) continue @@ -316,16 +339,24 @@ class SpotifyPlugin(BeetsPlugin): # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: - r_data = [x for x in r_data if region_filter in x['available_markets']] + r_data = [ + x + for x in r_data + if region_filter in x['available_markets'] + ] # Simplest, take the first result chosen_result = None if len(r_data) == 1 or self.config['tiebreak'].get() == "first": - self._log.debug(u'Spotify track(s) found, count: {0}', len(r_data)) + self._log.debug( + u'Spotify track(s) found, count: {0}', len(r_data) + ) chosen_result = r_data[0] elif len(r_data) > 1: # Use the popularity filter - self._log.debug(u'Most popular track chosen, count: {0}', len(r_data)) + self._log.debug( + u'Most popular track chosen, count: {0}', len(r_data) + ) chosen_result = max(r_data, key=lambda x: x['popularity']) if chosen_result: From 1a9f20edfe176c9bd9144e2d7af2b24dcae2695e Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 18:42:29 -0800 Subject: [PATCH 03/34] unregister `import_begin` listener --- beetsplug/spotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 2545813ac..697bfe940 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -52,7 +52,7 @@ class SpotifyPlugin(BeetsPlugin): self.tokenfile = self.config['tokenfile'].get( confit.Filename(in_app_dir=True) ) - self.register_listener('import_begin', self.setup) + # self.register_listener('import_begin', self.setup) def setup(self): """Retrieve previously saved OAuth token or generate a new one""" From 363997139102e1f77b542bedd22572b74cbe8141 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 18:48:46 -0800 Subject: [PATCH 04/34] remove unused import --- beetsplug/spotify.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 697bfe940..caaa06a55 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -2,7 +2,6 @@ from __future__ import division, absolute_import, print_function -import os import re import json import base64 From 160d66d05c0d3ec46133edff5bab70a73e768c24 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 19:04:15 -0800 Subject: [PATCH 05/34] b64encode with bytes --- beetsplug/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index caaa06a55..88a584f86 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -68,8 +68,8 @@ class SpotifyPlugin(BeetsPlugin): 'Authorization': 'Basic {}'.format( base64.b64encode( '{}:{}'.format( - self.config['client_id'].as_str(), - self.config['client_secret'].as_str(), + bytes(self.config['client_id'].as_str()), + bytes(self.config['client_secret'].as_str()), ) ) ) From 8bdd927d20eb18652fb4d2f36be70e646bb3c524 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 19:17:34 -0800 Subject: [PATCH 06/34] try b64 encode/decode --- beetsplug/spotify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 88a584f86..e51d32643 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -68,10 +68,10 @@ class SpotifyPlugin(BeetsPlugin): 'Authorization': 'Basic {}'.format( base64.b64encode( '{}:{}'.format( - bytes(self.config['client_id'].as_str()), - bytes(self.config['client_secret'].as_str()), + self.config['client_id'].as_str().encode(), + self.config['client_secret'].as_str().encode(), ) - ) + ).decode() ) } response = requests.post( From c1cb7a29411ac84f699876677fb7b8cec8cc5dbb Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 19:29:35 -0800 Subject: [PATCH 07/34] address py3 compatibility later --- beetsplug/spotify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index e51d32643..caaa06a55 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -68,10 +68,10 @@ class SpotifyPlugin(BeetsPlugin): 'Authorization': 'Basic {}'.format( base64.b64encode( '{}:{}'.format( - self.config['client_id'].as_str().encode(), - self.config['client_secret'].as_str().encode(), + self.config['client_id'].as_str(), + self.config['client_secret'].as_str(), ) - ).decode() + ) ) } response = requests.post( From e6c8f79a07cc0dea09b14d4eeac7ae55512564a8 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 22:55:40 -0800 Subject: [PATCH 08/34] resolve python2/3 bytes/str incompatibilities, simplify authentication --- beetsplug/spotify.py | 84 +++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index caaa06a55..661ea2775 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -51,7 +51,7 @@ class SpotifyPlugin(BeetsPlugin): self.tokenfile = self.config['tokenfile'].get( confit.Filename(in_app_dir=True) ) - # self.register_listener('import_begin', self.setup) + self.setup() def setup(self): """Retrieve previously saved OAuth token or generate a new one""" @@ -64,16 +64,13 @@ class SpotifyPlugin(BeetsPlugin): self.access_token = token_data['access_token'] def authenticate(self): - headers = { - 'Authorization': 'Basic {}'.format( - base64.b64encode( - '{}:{}'.format( - self.config['client_id'].as_str(), - self.config['client_secret'].as_str(), - ) - ) - ) - } + b64_encoded = base64.b64encode( + ':'.join( + self.config[k].as_str() for k in ('client_id', 'client_secret') + ).encode() + ).decode() + headers = {'Authorization': 'Basic {}'.format(b64_encoded)} + response = requests.post( self.oauth_token_url, data={'grant_type': 'client_credentials'}, @@ -96,8 +93,6 @@ class SpotifyPlugin(BeetsPlugin): @property def auth_header(self): - if not hasattr(self, 'access_token'): - self.setup() return {'Authorization': 'Bearer {}'.format(self.access_token)} def _handle_response(self, request_type, url, params=None): @@ -128,30 +123,32 @@ class SpotifyPlugin(BeetsPlugin): requests.get, self.album_url + spotify_album_id ) - data = response.json() + response_data = response.json() - artist, artist_id = self._get_artist(data['artists']) + artist, artist_id = self._get_artist(response_data['artists']) - date_parts = [int(part) for part in data['release_date'].split('-')] + date_parts = [ + int(part) for part in response_data['release_date'].split('-') + ] - if data['release_date_precision'] == 'day': + if response_data['release_date_precision'] == 'day': year, month, day = date_parts - elif data['release_date_precision'] == 'month': + elif response_data['release_date_precision'] == 'month': year, month = date_parts day = None - elif data['release_date_precision'] == 'year': + elif response_data['release_date_precision'] == 'year': year = date_parts month = None day = None album = AlbumInfo( - album=data['name'], + album=response_data['name'], album_id=album_id, artist=artist, artist_id=artist_id, tracks=None, asin=None, - albumtype=data['album_type'], + albumtype=response_data['album_type'], va=False, year=year, month=month, @@ -315,48 +312,49 @@ class SpotifyPlugin(BeetsPlugin): artist = item[self.config['artist_field'].get()] album = item[self.config['album_field'].get()] query = item[self.config['track_field'].get()] - search_url = query + " album:" + album + " artist:" + artist + search_url = '{} album:{} artist:{}'.format(query, album, artist) # Query the Web API for each track, look for the items' JSON data - r = self._handle_response( - requests.get, - self.base_url, - params={"q": search_url, "type": "track"}, - ) - self._log.debug('{}', r.url) try: - r.raise_for_status() - except requests.exceptions.HTTPError as e: - self._log.debug( - u'URL returned a {0} error', e.response.status_code + response = self._handle_response( + requests.get, + self.base_url, + params={'q': search_url, 'type': 'track'}, ) + except ui.UserError: failures.append(search_url) continue - r_data = r.json()['tracks']['items'] + response_data = response.json()['tracks']['items'] # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: - r_data = [ + response_data = [ x - for x in r_data + for x in response_data if region_filter in x['available_markets'] ] # Simplest, take the first result chosen_result = None - if len(r_data) == 1 or self.config['tiebreak'].get() == "first": + if ( + len(response_data) == 1 + or self.config['tiebreak'].get() == 'first' + ): self._log.debug( - u'Spotify track(s) found, count: {0}', len(r_data) + u'Spotify track(s) found, count: {0}', len(response_data) ) - chosen_result = r_data[0] - elif len(r_data) > 1: + chosen_result = response_data[0] + elif len(response_data) > 1: # Use the popularity filter self._log.debug( - u'Most popular track chosen, count: {0}', len(r_data) + u'Most popular track chosen, count: {0}', + len(response_data), + ) + chosen_result = max( + response_data, key=lambda x: x['popularity'] ) - chosen_result = max(r_data, key=lambda x: x['popularity']) if chosen_result: results.append(chosen_result) @@ -385,9 +383,9 @@ class SpotifyPlugin(BeetsPlugin): def output_results(self, results): if results: ids = [x['id'] for x in results] - if self.config['mode'].get() == "open": + if self.config['mode'].get() == 'open': self._log.info(u'Attempting to open Spotify with playlist') - spotify_url = self.playlist_partial + ",".join(ids) + spotify_url = self.playlist_partial + ','.join(ids) webbrowser.open(spotify_url) else: From dc77943da20fe3a18951bcc1d13f428402a47d7a Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 23:21:02 -0800 Subject: [PATCH 09/34] try oauth token mock --- beetsplug/spotify.py | 17 ++++++++------- test/test_spotify.py | 49 +++++++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 661ea2775..2537f0980 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -64,13 +64,16 @@ class SpotifyPlugin(BeetsPlugin): self.access_token = token_data['access_token'] def authenticate(self): - b64_encoded = base64.b64encode( - ':'.join( - self.config[k].as_str() for k in ('client_id', 'client_secret') - ).encode() - ).decode() - headers = {'Authorization': 'Basic {}'.format(b64_encoded)} - + headers = { + 'Authorization': 'Basic {}'.format( + base64.b64encode( + ':'.join( + self.config[k].as_str() + for k in ('client_id', 'client_secret') + ).encode() + ).decode() + ) + } response = requests.post( self.oauth_token_url, data={'grant_type': 'client_credentials'}, diff --git a/test/test_spotify.py b/test/test_spotify.py index 17f3ef42f..ce178447c 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -29,10 +29,21 @@ def _params(url): class SpotifyPluginTest(_common.TestCase, TestHelper): - + @responses.activate def setUp(self): config.clear() self.setup_beets() + responses.add( + responses.POST, + spotify.SpotifyPlugin.oauth_token_url, + status=200, + json={ + 'access_token': '3XyiC3raJySbIAV5LVYj1DaWbcocNi3LAJTNXRnYYGVUl6mbbqXNhW3YcZnQgYXNWHFkVGSMlc0tMuvq8CF', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'scope': '', + }, + ) self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) self.spotify.parse_opts(opts) @@ -51,20 +62,25 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): @responses.activate def test_missing_request(self): - json_file = os.path.join(_common.RSRC, b'spotify', - b'missing_request.json') + json_file = os.path.join( + _common.RSRC, b'spotify', b'missing_request.json' + ) with open(json_file, 'rb') as f: response_body = f.read() - responses.add(responses.GET, 'https://api.spotify.com/v1/search', - body=response_body, status=200, - content_type='application/json') + responses.add( + responses.GET, + spotify.SpotifyPlugin.base_url, + body=response_body, + status=200, + content_type='application/json', + ) item = Item( mb_trackid=u'01234', album=u'lkajsdflakjsd', albumartist=u'ujydfsuihse', title=u'duifhjslkef', - length=10 + length=10, ) item.add(self.lib) self.assertEqual([], self.spotify.query_spotify(self.lib, u"")) @@ -78,21 +94,25 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): @responses.activate def test_track_request(self): - - json_file = os.path.join(_common.RSRC, b'spotify', - b'track_request.json') + json_file = os.path.join( + _common.RSRC, b'spotify', b'track_request.json' + ) with open(json_file, 'rb') as f: response_body = f.read() - responses.add(responses.GET, 'https://api.spotify.com/v1/search', - body=response_body, status=200, - content_type='application/json') + responses.add( + responses.GET, + 'https://api.spotify.com/v1/search', + body=response_body, + status=200, + content_type='application/json', + ) item = Item( mb_trackid=u'01234', album=u'Despicable Me 2', albumartist=u'Pharrell Williams', title=u'Happy', - length=10 + length=10, ) item.add(self.lib) results = self.spotify.query_spotify(self.lib, u"Happy") @@ -111,5 +131,6 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): def suite(): return unittest.TestLoader().loadTestsFromName(__name__) + if __name__ == '__main__': unittest.main(defaultTest='suite') From 337cf2a1c3590e9b8dd79aefdd13136b3b28d419 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 23:35:06 -0800 Subject: [PATCH 10/34] appease Flake8 --- beetsplug/spotify.py | 14 ++++++++------ test/test_spotify.py | 17 +++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 2537f0980..6cc40ae87 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -16,7 +16,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo class SpotifyPlugin(BeetsPlugin): - # URL for the Web API of Spotify + # Endpoints for the Spotify API # Documentation here: https://developer.spotify.com/web-api/search-item/ oauth_token_url = 'https://accounts.spotify.com/api/token' base_url = 'https://api.spotify.com/v1/search' @@ -54,7 +54,9 @@ class SpotifyPlugin(BeetsPlugin): self.setup() def setup(self): - """Retrieve previously saved OAuth token or generate a new one""" + """ + Retrieve previously saved OAuth token or generate a new one + """ try: with open(self.tokenfile) as f: token_data = json.load(f) @@ -113,8 +115,8 @@ class SpotifyPlugin(BeetsPlugin): def album_for_id(self, album_id): """ - Fetches an album by its Spotify album ID or URL and returns an AlbumInfo object - or None if the album is not found. + Fetches an album by its Spotify album ID or URL and returns an + AlbumInfo object or None if the album is not found. """ self._log.debug(u'Searching for album {}', album_id) match = re.search(self.id_regex.format('album'), album_id) @@ -180,8 +182,8 @@ class SpotifyPlugin(BeetsPlugin): def track_for_id(self, track_id): """ - Fetches a track by its Spotify track ID or URL and returns a TrackInfo object - or None if the track is not found. + Fetches a track by its Spotify track ID or URL and returns a + TrackInfo object or None if the track is not found. """ self._log.debug(u'Searching for track {}', track_id) match = re.search(self.id_regex.format('track'), track_id) diff --git a/test/test_spotify.py b/test/test_spotify.py index ce178447c..e1e53b001 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -38,27 +38,28 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): spotify.SpotifyPlugin.oauth_token_url, status=200, json={ - 'access_token': '3XyiC3raJySbIAV5LVYj1DaWbcocNi3LAJTNXRnYYGVUl6mbbqXNhW3YcZnQgYXNWHFkVGSMlc0tMuvq8CF', + 'access_token': '3XyiC3raJySbIAV5LVYj1DaWbcocNi3LAJTNXRnYY' + 'GVUl6mbbqXNhW3YcZnQgYXNWHFkVGSMlc0tMuvq8CF', 'token_type': 'Bearer', 'expires_in': 3600, 'scope': '', }, ) self.spotify = spotify.SpotifyPlugin() - opts = ArgumentsMock("list", False) + opts = ArgumentsMock('list', False) self.spotify.parse_opts(opts) def tearDown(self): self.teardown_beets() def test_args(self): - opts = ArgumentsMock("fail", True) + opts = ArgumentsMock('fail', True) self.assertEqual(False, self.spotify.parse_opts(opts)) - opts = ArgumentsMock("list", False) + opts = ArgumentsMock('list', False) self.assertEqual(True, self.spotify.parse_opts(opts)) def test_empty_query(self): - self.assertEqual(None, self.spotify.query_spotify(self.lib, u"1=2")) + self.assertEqual(None, self.spotify.query_spotify(self.lib, u'1=2')) @responses.activate def test_missing_request(self): @@ -102,7 +103,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): responses.add( responses.GET, - 'https://api.spotify.com/v1/search', + spotify.SpotifyPlugin.base_url, body=response_body, status=200, content_type='application/json', @@ -115,9 +116,9 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): length=10, ) item.add(self.lib) - results = self.spotify.query_spotify(self.lib, u"Happy") + results = self.spotify.query_spotify(self.lib, u'Happy') self.assertEqual(1, len(results)) - self.assertEqual(u"6NPVjNh8Jhru9xOmyQigds", results[0]['id']) + self.assertEqual(u'6NPVjNh8Jhru9xOmyQigds', results[0]['id']) self.spotify.output_results(results) params = _params(responses.calls[0].request.url) From 104f6185ab199b7a434e84f9eb7e6b40837cab11 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 23:57:36 -0800 Subject: [PATCH 11/34] revert unnecessary double --> single quotes --- beetsplug/spotify.py | 4 ++-- test/test_spotify.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 6cc40ae87..dc1c9cd28 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -388,9 +388,9 @@ class SpotifyPlugin(BeetsPlugin): def output_results(self, results): if results: ids = [x['id'] for x in results] - if self.config['mode'].get() == 'open': + if self.config['mode'].get() == "open": self._log.info(u'Attempting to open Spotify with playlist') - spotify_url = self.playlist_partial + ','.join(ids) + spotify_url = self.playlist_partial + ",".join(ids) webbrowser.open(spotify_url) else: diff --git a/test/test_spotify.py b/test/test_spotify.py index e1e53b001..92c1e5575 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -46,20 +46,20 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): }, ) self.spotify = spotify.SpotifyPlugin() - opts = ArgumentsMock('list', False) + opts = ArgumentsMock("list", False) self.spotify.parse_opts(opts) def tearDown(self): self.teardown_beets() def test_args(self): - opts = ArgumentsMock('fail', True) + opts = ArgumentsMock("fail", True) self.assertEqual(False, self.spotify.parse_opts(opts)) - opts = ArgumentsMock('list', False) + opts = ArgumentsMock("list", False) self.assertEqual(True, self.spotify.parse_opts(opts)) def test_empty_query(self): - self.assertEqual(None, self.spotify.query_spotify(self.lib, u'1=2')) + self.assertEqual(None, self.spotify.query_spotify(self.lib, u"1=2")) @responses.activate def test_missing_request(self): @@ -118,7 +118,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): item.add(self.lib) results = self.spotify.query_spotify(self.lib, u'Happy') self.assertEqual(1, len(results)) - self.assertEqual(u'6NPVjNh8Jhru9xOmyQigds', results[0]['id']) + self.assertEqual(u"6NPVjNh8Jhru9xOmyQigds", results[0]['id']) self.spotify.output_results(results) params = _params(responses.calls[0].request.url) From 3309c555ed9361e07072f87f44fdfecf780ca95a Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 00:05:56 -0800 Subject: [PATCH 12/34] better naming, documentation --- beetsplug/spotify.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dc1c9cd28..0b4866286 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -61,11 +61,15 @@ class SpotifyPlugin(BeetsPlugin): with open(self.tokenfile) as f: token_data = json.load(f) except IOError: - self.authenticate() + self._authenticate() else: self.access_token = token_data['access_token'] - def authenticate(self): + def _authenticate(self): + """ + Request an access token via the Client Credentials Flow: + https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow + """ headers = { 'Authorization': 'Basic {}'.format( base64.b64encode( @@ -97,17 +101,17 @@ class SpotifyPlugin(BeetsPlugin): json.dump({'access_token': self.access_token}, f) @property - def auth_header(self): + def _auth_header(self): return {'Authorization': 'Bearer {}'.format(self.access_token)} def _handle_response(self, request_type, url, params=None): - response = request_type(url, headers=self.auth_header, params=params) + response = request_type(url, headers=self._auth_header, params=params) if response.status_code != 200: if u'token expired' in response.text: self._log.debug( 'Spotify access token has expired. Reauthenticating.' ) - self.authenticate() + self._authenticate() self._handle_response(request_type, url, params=params) else: raise ui.UserError(u'Spotify API error:\n{}', response.text) From e95b8a6ee0431d6ca4b7ad6da7bfea66554b389e Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 00:41:14 -0800 Subject: [PATCH 13/34] add docstrings, separate TrackInfo generation --- beetsplug/spotify.py | 84 +++++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 0b4866286..597d70011 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -17,7 +17,7 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo class SpotifyPlugin(BeetsPlugin): # Endpoints for the Spotify API - # Documentation here: https://developer.spotify.com/web-api/search-item/ + # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' base_url = 'https://api.spotify.com/v1/search' open_url = 'http://open.spotify.com/track/' @@ -119,7 +119,7 @@ class SpotifyPlugin(BeetsPlugin): def album_for_id(self, album_id): """ - Fetches an album by its Spotify album ID or URL and returns an + Fetches an album by its Spotify ID or URL and returns an AlbumInfo object or None if the album is not found. """ self._log.debug(u'Searching for album {}', album_id) @@ -131,7 +131,6 @@ class SpotifyPlugin(BeetsPlugin): response = self._handle_response( requests.get, self.album_url + spotify_album_id ) - response_data = response.json() artist, artist_id = self._get_artist(response_data['artists']) @@ -140,15 +139,21 @@ class SpotifyPlugin(BeetsPlugin): int(part) for part in response_data['release_date'].split('-') ] - if response_data['release_date_precision'] == 'day': + release_date_precision = response_data['release_date_precision'] + if release_date_precision == 'day': year, month, day = date_parts - elif response_data['release_date_precision'] == 'month': + elif release_date_precision == 'month': year, month = date_parts day = None - elif response_data['release_date_precision'] == 'year': + elif release_date_precision == 'year': year = date_parts month = None day = None + else: + raise ui.UserError( + u"Invalid `release_date_precision` returned " + u"from Spotify API: '{}'".format(release_date_precision) + ) album = AlbumInfo( album=response_data['name'], @@ -162,7 +167,7 @@ class SpotifyPlugin(BeetsPlugin): year=year, month=month, day=day, - label=None, + label=response_data['label'], mediums=None, artist_sort=None, releasegroup_id=None, @@ -184,32 +189,27 @@ class SpotifyPlugin(BeetsPlugin): return album - def track_for_id(self, track_id): + def _get_track(self, track_data): """ - Fetches a track by its Spotify track ID or URL and returns a - TrackInfo object or None if the track is not found. - """ - self._log.debug(u'Searching for track {}', track_id) - match = re.search(self.id_regex.format('track'), track_id) - if not match: - return None - spotify_track_id = match.group(2) + Convert a Spotify track object dict to a TrackInfo object. - response = self._handle_response( - requests.get, self.track_url + spotify_track_id - ) - data = response.json() - artist, artist_id = self._get_artist(data['artists']) - track = TrackInfo( - title=data['title'], - track_id=spotify_track_id, - release_track_id=data.get('album').get('id'), + :param track_data: Simplified track object + (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) + :type track_data: dict + :return: TrackInfo object for track + :rtype: beets.autotag.hooks.TrackInfo + """ + artist, artist_id = self._get_artist(track_data['artists']) + return TrackInfo( + title=track_data['title'], + track_id=track_data['id'], + release_track_id=track_data.get('album').get('id'), artist=artist, artist_id=artist_id, - length=data['duration_ms'] / 1000, + length=track_data['duration_ms'] / 1000, index=None, medium=None, - medium_index=data['track_number'], + medium_index=track_data['track_number'], medium_total=None, artist_sort=None, disctitle=None, @@ -223,12 +223,38 @@ class SpotifyPlugin(BeetsPlugin): arranger=None, track_alt=None, ) - return track + + def track_for_id(self, track_id): + """ + Fetches a track by its Spotify ID or URL and returns a + TrackInfo object or None if the track is not found. + + :param track_id: Spotify ID or URL for the track + :type track_id: str + :return: TrackInfo object for track + :rtype: beets.autotag.hooks.TrackInfo + """ + self._log.debug(u'Searching for track {}', track_id) + match = re.search(self.id_regex.format('track'), track_id) + if not match: + return None + spotify_track_id = match.group(2) + + response = self._handle_response( + requests.get, self.track_url + spotify_track_id + ) + return self._get_track(response.json()) def _get_artist(self, artists): """ Returns an artist string (all artists) and an artist_id (the main - artist) for a list of Beatport release or track artists. + artist) for a list of Spotify artist object dicts. + + :param artists: Iterable of simplified Spotify artist objects + (https://developer.spotify.com/documentation/web-api/reference/object-model/#artist-object-simplified) + :type artists: list[dict] + :return: Normalized artist string + :rtype: str """ artist_id = None artist_names = [] From 91b2e33569de90149eb39403c588fefa427588b6 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 01:33:19 -0800 Subject: [PATCH 14/34] support album autotagging --- beetsplug/spotify.py | 56 +++++++++++++------------------------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 597d70011..cd7395689 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -132,7 +132,6 @@ class SpotifyPlugin(BeetsPlugin): requests.get, self.album_url + spotify_album_id ) response_data = response.json() - artist, artist_id = self._get_artist(response_data['artists']) date_parts = [ @@ -155,40 +154,30 @@ class SpotifyPlugin(BeetsPlugin): u"from Spotify API: '{}'".format(release_date_precision) ) - album = AlbumInfo( + tracks = [] + for i, track_data in enumerate(response_data['tracks']['items']): + track = self._get_track(track_data) + track.index = i + 1 + tracks.append(track) + + return AlbumInfo( album=response_data['name'], album_id=album_id, artist=artist, artist_id=artist_id, - tracks=None, - asin=None, + tracks=tracks, albumtype=response_data['album_type'], - va=False, + va=len(response_data['artists']) == 1 + and artist_id + == '0LyfQWJT6nXafLPZqxe9Of', # Spotify ID for "Various Artists" year=year, month=month, day=day, label=response_data['label'], - mediums=None, - artist_sort=None, - releasegroup_id=None, - catalognum=None, - script=None, - language=None, - country=None, - albumstatus=None, - media=None, - albumdisambig=None, - releasegroupdisambig=None, - artist_credit=None, - original_year=None, - original_month=None, - original_day=None, data_source='Spotify', - data_url=None, + data_url=response_data['uri'], ) - return album - def _get_track(self, track_data): """ Convert a Spotify track object dict to a TrackInfo object. @@ -201,27 +190,15 @@ class SpotifyPlugin(BeetsPlugin): """ artist, artist_id = self._get_artist(track_data['artists']) return TrackInfo( - title=track_data['title'], + title=track_data['name'], track_id=track_data['id'], - release_track_id=track_data.get('album').get('id'), artist=artist, artist_id=artist_id, length=track_data['duration_ms'] / 1000, - index=None, - medium=None, + index=track_data['track_number'], medium_index=track_data['track_number'], - medium_total=None, - artist_sort=None, - disctitle=None, - artist_credit=None, - data_source=None, - data_url=None, - media=None, - lyricist=None, - composer=None, - composer_sort=None, - arranger=None, - track_alt=None, + data_source='Spotify', + data_url=track_data['uri'], ) def track_for_id(self, track_id): @@ -328,7 +305,6 @@ class SpotifyPlugin(BeetsPlugin): self._log.info(u'Processing {0} tracks...', len(items)) for item in items: - # Apply regex transformations if provided for regex in self.config['regex'].get(): if ( From 60c9201e4a9365412847cbe1d1ddb3c650db3cf2 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 01:54:08 -0800 Subject: [PATCH 15/34] modularize Spotify ID parsing --- beetsplug/spotify.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index cd7395689..08f173918 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -117,19 +117,30 @@ class SpotifyPlugin(BeetsPlugin): raise ui.UserError(u'Spotify API error:\n{}', response.text) return response + def _get_spotify_id(self, url_type, id_): + """ + Parse a Spotify ID from its URL if necessary. + + :param url_type: Type of Spotify URL, either 'album' or 'track' + :type url_type: str + :return: Spotify ID + :rtype: str + """ + self._log.debug(u'Searching for {} {}', url_type, id_) + match = re.search(self.id_regex.format(url_type), id_) + return match.group(2) if match else None + def album_for_id(self, album_id): """ Fetches an album by its Spotify ID or URL and returns an AlbumInfo object or None if the album is not found. """ - self._log.debug(u'Searching for album {}', album_id) - match = re.search(self.id_regex.format('album'), album_id) - if not match: + spotify_id = self._get_spotify_id('album', album_id) + if spotify_id is None: return None - spotify_album_id = match.group(2) response = self._handle_response( - requests.get, self.album_url + spotify_album_id + requests.get, self.album_url + spotify_id ) response_data = response.json() artist, artist_id = self._get_artist(response_data['artists']) @@ -168,8 +179,7 @@ class SpotifyPlugin(BeetsPlugin): tracks=tracks, albumtype=response_data['album_type'], va=len(response_data['artists']) == 1 - and artist_id - == '0LyfQWJT6nXafLPZqxe9Of', # Spotify ID for "Various Artists" + and artist.lower() == 'various artists', year=year, month=month, day=day, @@ -211,14 +221,12 @@ class SpotifyPlugin(BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - self._log.debug(u'Searching for track {}', track_id) - match = re.search(self.id_regex.format('track'), track_id) - if not match: + spotify_id = self._get_spotify_id('track', track_id) + if spotify_id is None: return None - spotify_track_id = match.group(2) response = self._handle_response( - requests.get, self.track_url + spotify_track_id + requests.get, self.track_url + spotify_id ) return self._get_track(response.json()) From 9a30000b567bcfe4f77b3812e5eef1a338e7e11f Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 02:04:46 -0800 Subject: [PATCH 16/34] better naming, formatting --- beetsplug/spotify.py | 27 ++++++++++++++++----------- test/test_spotify.py | 2 +- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 08f173918..15a57c788 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -16,11 +16,11 @@ from beets.autotag.hooks import AlbumInfo, TrackInfo class SpotifyPlugin(BeetsPlugin): - # Endpoints for the Spotify API + # Base URLs for the Spotify API # Documentation: https://developer.spotify.com/web-api oauth_token_url = 'https://accounts.spotify.com/api/token' - base_url = 'https://api.spotify.com/v1/search' - open_url = 'http://open.spotify.com/track/' + open_track_url = 'http://open.spotify.com/track/' + search_url = 'https://api.spotify.com/v1/search' album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' playlist_partial = 'spotify:trackset:Playlist:' @@ -55,7 +55,7 @@ class SpotifyPlugin(BeetsPlugin): def setup(self): """ - Retrieve previously saved OAuth token or generate a new one + Retrieve previously saved OAuth token or generate a new one. """ try: with open(self.tokenfile) as f: @@ -331,17 +331,19 @@ class SpotifyPlugin(BeetsPlugin): artist = item[self.config['artist_field'].get()] album = item[self.config['album_field'].get()] query = item[self.config['track_field'].get()] - search_url = '{} album:{} artist:{}'.format(query, album, artist) + query_keywords = '{} album:{} artist:{}'.format( + query, album, artist + ) # Query the Web API for each track, look for the items' JSON data try: response = self._handle_response( requests.get, - self.base_url, - params={'q': search_url, 'type': 'track'}, + self.search_url, + params={'q': query_keywords, 'type': 'track'}, ) except ui.UserError: - failures.append(search_url) + failures.append(query_keywords) continue response_data = response.json()['tracks']['items'] @@ -378,8 +380,11 @@ class SpotifyPlugin(BeetsPlugin): if chosen_result: results.append(chosen_result) else: - self._log.debug(u'No spotify track found: {0}', search_url) - failures.append(search_url) + self._log.debug( + u'No Spotify track found for the following query: {}', + query_keywords, + ) + failures.append(query_keywords) failure_count = len(failures) if failure_count > 0: @@ -409,6 +414,6 @@ class SpotifyPlugin(BeetsPlugin): else: for item in ids: - print(self.open_url + item) + print(self.open_track_url + item) else: self._log.warning(u'No Spotify tracks found from beets query') diff --git a/test/test_spotify.py b/test/test_spotify.py index 92c1e5575..90a629c34 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -116,7 +116,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): length=10, ) item.add(self.lib) - results = self.spotify.query_spotify(self.lib, u'Happy') + results = self.spotify.query_spotify(self.lib, u"Happy") self.assertEqual(1, len(results)) self.assertEqual(u"6NPVjNh8Jhru9xOmyQigds", results[0]['id']) self.spotify.output_results(results) From b95eaa8ffe8e2f6fad3adc056b0ce6ab4c9ac029 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 02:20:10 -0800 Subject: [PATCH 17/34] fix test, document Spotify ID --- beetsplug/spotify.py | 6 ++++-- test/test_spotify.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 15a57c788..6a211052e 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -24,7 +24,6 @@ class SpotifyPlugin(BeetsPlugin): album_url = 'https://api.spotify.com/v1/albums/' track_url = 'https://api.spotify.com/v1/tracks/' playlist_partial = 'spotify:trackset:Playlist:' - id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})' def __init__(self): super(SpotifyPlugin, self).__init__() @@ -126,8 +125,11 @@ class SpotifyPlugin(BeetsPlugin): :return: Spotify ID :rtype: str """ + # Spotify IDs consist of 22 alphanumeric characters + # (zero-left-padded base62 representation of randomly generated UUID4) + id_regex = r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})' self._log.debug(u'Searching for {} {}', url_type, id_) - match = re.search(self.id_regex.format(url_type), id_) + match = re.search(id_regex.format(url_type), id_) return match.group(2) if match else None def album_for_id(self, album_id): diff --git a/test/test_spotify.py b/test/test_spotify.py index 90a629c34..221c21e74 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -71,7 +71,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): responses.add( responses.GET, - spotify.SpotifyPlugin.base_url, + spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type='application/json', @@ -103,7 +103,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): responses.add( responses.GET, - spotify.SpotifyPlugin.base_url, + spotify.SpotifyPlugin.search_url, body=response_body, status=200, content_type='application/json', From 02aa79ae61698c601738c0bdf0fafcfaf0d48727 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 02:28:59 -0800 Subject: [PATCH 18/34] add more docstrings --- beetsplug/spotify.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 6a211052e..260d767ce 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -104,6 +104,20 @@ class SpotifyPlugin(BeetsPlugin): return {'Authorization': 'Bearer {}'.format(self.access_token)} def _handle_response(self, request_type, url, params=None): + """ + Send a request, reauthenticating if necessary. + + :param request_type: Type of :class:`Request` constructor, + e.g. ``requests.get``, ``requests.post``, etc. + :type request_type: function + :param url: URL for the new :class:`Request` object. + :type url: str + :param params: (optional) list of tuples or bytes to send + in the query string for the :class:`Request`. + :type params: dict + :return: class:`Response ` object + :rtype: requests.Response + """ response = request_type(url, headers=self._auth_header, params=params) if response.status_code != 200: if u'token expired' in response.text: @@ -122,6 +136,8 @@ class SpotifyPlugin(BeetsPlugin): :param url_type: Type of Spotify URL, either 'album' or 'track' :type url_type: str + :param id_: Spotify ID or URL + :type id_: str :return: Spotify ID :rtype: str """ From bb1ed67e2d67a4df8395afef6320fe49771addaa Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 02:43:54 -0800 Subject: [PATCH 19/34] use open.spotify.com URL for `data_url` --- beetsplug/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 260d767ce..2e8d10f1a 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -203,7 +203,7 @@ class SpotifyPlugin(BeetsPlugin): day=day, label=response_data['label'], data_source='Spotify', - data_url=response_data['uri'], + data_url=response_data['external_urls']['spotify'], ) def _get_track(self, track_data): @@ -226,7 +226,7 @@ class SpotifyPlugin(BeetsPlugin): index=track_data['track_number'], medium_index=track_data['track_number'], data_source='Spotify', - data_url=track_data['uri'], + data_url=track_data['external_urls']['spotify'], ) def track_for_id(self, track_id): From 695dbfaf8054899d7169b042fa3b57e9b1f00338 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 03:20:18 -0800 Subject: [PATCH 20/34] copy album_distance, track_distance from Beatport plugin --- beetsplug/spotify.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 2e8d10f1a..17d676665 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -12,7 +12,7 @@ import requests from beets import ui from beets.plugins import BeetsPlugin from beets.util import confit -from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance class SpotifyPlugin(BeetsPlugin): @@ -273,6 +273,26 @@ class SpotifyPlugin(BeetsPlugin): artist = ', '.join(artist_names).replace(' ,', ',') or None return artist, artist_id + def album_distance(self, items, album_info, mapping): + """ + Returns the Spotify source weight and the maximum source weight + for albums. + """ + dist = Distance() + if album_info.data_source == 'Spotify': + dist.add('source', self.config['source_weight'].as_number()) + return dist + + def track_distance(self, item, track_info): + """ + Returns the Spotify source weight and the maximum source weight + for individual tracks. + """ + dist = Distance() + if track_info.data_source == 'Spotify': + dist.add('source', self.config['source_weight'].as_number()) + return dist + def commands(self): def queries(lib, opts, args): success = self.parse_opts(opts) From 287c767a6de01bc1627d5ee78b5cf5872cfc7e60 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 11:24:33 -0800 Subject: [PATCH 21/34] fix formatting --- beetsplug/spotify.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 17d676665..ef6f26e48 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -46,16 +46,13 @@ class SpotifyPlugin(BeetsPlugin): ) self.config['client_secret'].redact = True - """Path to the JSON file for storing the OAuth access token.""" self.tokenfile = self.config['tokenfile'].get( confit.Filename(in_app_dir=True) - ) + ) # Path to the JSON file for storing the OAuth access token. self.setup() def setup(self): - """ - Retrieve previously saved OAuth token or generate a new one. - """ + """Retrieve previously saved OAuth token or generate a new one.""" try: with open(self.tokenfile) as f: token_data = json.load(f) @@ -65,8 +62,7 @@ class SpotifyPlugin(BeetsPlugin): self.access_token = token_data['access_token'] def _authenticate(self): - """ - Request an access token via the Client Credentials Flow: + """Request an access token via the Client Credentials Flow: https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow """ headers = { @@ -104,8 +100,7 @@ class SpotifyPlugin(BeetsPlugin): return {'Authorization': 'Bearer {}'.format(self.access_token)} def _handle_response(self, request_type, url, params=None): - """ - Send a request, reauthenticating if necessary. + """Send a request, reauthenticating if necessary. :param request_type: Type of :class:`Request` constructor, e.g. ``requests.get``, ``requests.post``, etc. @@ -131,8 +126,7 @@ class SpotifyPlugin(BeetsPlugin): return response def _get_spotify_id(self, url_type, id_): - """ - Parse a Spotify ID from its URL if necessary. + """Parse a Spotify ID from its URL if necessary. :param url_type: Type of Spotify URL, either 'album' or 'track' :type url_type: str @@ -149,8 +143,7 @@ class SpotifyPlugin(BeetsPlugin): return match.group(2) if match else None def album_for_id(self, album_id): - """ - Fetches an album by its Spotify ID or URL and returns an + """Fetches an album by its Spotify ID or URL and returns an AlbumInfo object or None if the album is not found. """ spotify_id = self._get_spotify_id('album', album_id) @@ -207,8 +200,7 @@ class SpotifyPlugin(BeetsPlugin): ) def _get_track(self, track_data): - """ - Convert a Spotify track object dict to a TrackInfo object. + """Convert a Spotify track object dict to a TrackInfo object. :param track_data: Simplified track object (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) @@ -230,8 +222,7 @@ class SpotifyPlugin(BeetsPlugin): ) def track_for_id(self, track_id): - """ - Fetches a track by its Spotify ID or URL and returns a + """Fetches a track by its Spotify ID or URL and returns a TrackInfo object or None if the track is not found. :param track_id: Spotify ID or URL for the track @@ -249,8 +240,7 @@ class SpotifyPlugin(BeetsPlugin): return self._get_track(response.json()) def _get_artist(self, artists): - """ - Returns an artist string (all artists) and an artist_id (the main + """Returns an artist string (all artists) and an artist_id (the main artist) for a list of Spotify artist object dicts. :param artists: Iterable of simplified Spotify artist objects @@ -274,8 +264,7 @@ class SpotifyPlugin(BeetsPlugin): return artist, artist_id def album_distance(self, items, album_info, mapping): - """ - Returns the Spotify source weight and the maximum source weight + """Returns the Spotify source weight and the maximum source weight for albums. """ dist = Distance() @@ -284,8 +273,7 @@ class SpotifyPlugin(BeetsPlugin): return dist def track_distance(self, item, track_info): - """ - Returns the Spotify source weight and the maximum source weight + """Returns the Spotify source weight and the maximum source weight for individual tracks. """ dist = Distance() @@ -344,7 +332,7 @@ class SpotifyPlugin(BeetsPlugin): if not items: self._log.debug( - u'Your beets query returned no items, ' u'skipping spotify' + u'Your beets query returned no items, skipping Spotify' ) return From 082357b063ef9d06771508f8757a5b87d489052c Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 12:40:11 -0800 Subject: [PATCH 22/34] document new functionality, use Spotify ID for AlbumInfo.album_id --- beetsplug/spotify.py | 3 +- docs/plugins/spotify.rst | 60 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ef6f26e48..713c670f0 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -41,7 +41,6 @@ class SpotifyPlugin(BeetsPlugin): 'client_secret': '6DRS7k66h4643yQEbepPxOuxeVW0yZpk', 'tokenfile': 'spotify_token.json', 'source_weight': 0.5, - 'user_token': '', } ) self.config['client_secret'].redact = True @@ -184,7 +183,7 @@ class SpotifyPlugin(BeetsPlugin): return AlbumInfo( album=response_data['name'], - album_id=album_id, + album_id=spotify_id, artist=artist, artist_id=artist_id, tracks=tracks, diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index b993a66d2..fb0cec9ef 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -1,10 +1,20 @@ Spotify Plugin ============== -The ``spotify`` plugin generates `Spotify`_ playlists from tracks in your library. Using the `Spotify Web API`_, any tracks that can be matched with a Spotify ID are returned, and the results can be either pasted in to a playlist or opened directly in the Spotify app. +The ``spotify`` plugin generates `Spotify`_ playlists from tracks in your +library with the ``beet spotify`` command. Using the `Spotify Search API`_, +any tracks that can be matched with a Spotify ID are returned, and the +results can be either pasted in to a playlist or opened directly in the +Spotify app. + +Spotify URLs and IDs may also be provided in the ``Enter release ID:`` prompt +during ``beet import`` to autotag music with data from the Spotify +`Album`_ and `Track`_ APIs. .. _Spotify: https://www.spotify.com/ -.. _Spotify Web API: https://developer.spotify.com/web-api/search-item/ +.. _Spotify Search API: https://developer.spotify.com/documentation/web-api/reference/search/search/ +.. _Album: https://developer.spotify.com/documentation/web-api/reference/albums/get-album/ +.. _Track: https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/ Why Use This Plugin? -------------------- @@ -12,12 +22,23 @@ Why Use This Plugin? * You're a Beets user and Spotify user already. * You have playlists or albums you'd like to make available in Spotify from Beets without having to search for each artist/album/track. * You want to check which tracks in your library are available on Spotify. +* You want to autotag music with Spotify metadata Basic Usage ----------- -First, enable the ``spotify`` plugin (see :ref:`using-plugins`). -Then, use the ``spotify`` command with a beets query:: +First, register a `Spotify application`_ to use with beets and add your Client ID +and Client Secret to your :doc:`configuration file ` under a +``spotify`` section:: + + spotify: + client_id: N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN + client_secret: 6DRS7k66h4643yQEbepPxOuxeVW0yZpk + +.. _Spotify application: https://developer.spotify.com/documentation/general/guides/app-settings/ + +Then, enable the ``spotify`` plugin (see :ref:`using-plugins`) and use the ``spotify`` +command with a beets query:: beet spotify [OPTIONS...] QUERY @@ -37,6 +58,24 @@ Command-line options include: * ``--show-failures`` or ``-f``: List the tracks that did not match a Spotify ID. +A Spotify ID or URL may also be provided to the ``Enter release ID`` +prompt during import:: + + Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i + Enter release ID: https://open.spotify.com/album/2rFYTHFBLQN3AYlrymBPPA + Tagging: + Bear Hands - Blue Lips / Ignoring the Truth / Back Seat Driver (Spirit Guide) / 2AM + URL: + https://open.spotify.com/album/2rFYTHFBLQN3AYlrymBPPA + (Similarity: 88.2%) (source, tracks) (Spotify, 2019, Spensive Sounds) + * Blue Lips (feat. Ursula Rose) -> Blue Lips (feat. Ursula Rose) (source) + * Ignoring the Truth -> Ignoring the Truth (source) + * Back Seat Driver (Spirit Guide) -> Back Seat Driver (Spirit Guide) (source) + * 2AM -> 2AM (source) + [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, + Enter search, enter Id, aBort, eDit, edit Candidates, plaY? + + Configuration ------------- @@ -67,10 +106,23 @@ in config.yaml under the ``spotify:`` section: track/album/artist fields before sending them to Spotify. Can be useful for changing certain abbreviations, like ft. -> feat. See the examples below. Default: None. +- **tokenfile**: Filename of the JSON file stored in the beets configuration + directory to use for caching the OAuth access token. + access token. + Default: ``spotify_token.json``. +- **source_weight**: Penalty applied to Spotify matches during import. Set to + 0.0 to disable. + Default: ``0.5``. + +.. _beets configuration directory: https://beets.readthedocs.io/en/stable/reference/config.html#default-location Here's an example:: spotify: + client_id: N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN + client_secret: 6DRS7k66h4643yQEbepPxOuxeVW0yZpk + source_weight: 0.7 + tokenfile: my_spotify_token.json mode: open region_filter: US show_failures: on From 2da738b0786c35d743a72ea45523bb763c239d20 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 12:50:28 -0800 Subject: [PATCH 23/34] remove unused doc --- docs/plugins/spotify.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index fb0cec9ef..cd7e2144a 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -108,14 +108,11 @@ in config.yaml under the ``spotify:`` section: Default: None. - **tokenfile**: Filename of the JSON file stored in the beets configuration directory to use for caching the OAuth access token. - access token. Default: ``spotify_token.json``. - **source_weight**: Penalty applied to Spotify matches during import. Set to 0.0 to disable. Default: ``0.5``. -.. _beets configuration directory: https://beets.readthedocs.io/en/stable/reference/config.html#default-location - Here's an example:: spotify: From 645c053a1cd4a6cb568303d471d08c8bcdc1ac1b Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 12:55:43 -0800 Subject: [PATCH 24/34] doc wording/formatting --- docs/plugins/spotify.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index cd7e2144a..51537b456 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -22,7 +22,7 @@ Why Use This Plugin? * You're a Beets user and Spotify user already. * You have playlists or albums you'd like to make available in Spotify from Beets without having to search for each artist/album/track. * You want to check which tracks in your library are available on Spotify. -* You want to autotag music with Spotify metadata +* You want to autotag music with metadata from the Spotify API. Basic Usage ----------- @@ -58,7 +58,7 @@ Command-line options include: * ``--show-failures`` or ``-f``: List the tracks that did not match a Spotify ID. -A Spotify ID or URL may also be provided to the ``Enter release ID`` +A Spotify ID or URL may also be provided to the ``Enter release ID:`` prompt during import:: Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i From 7b1e64a61fd40ab2d8fdc5587eba2b54c2272af4 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 13:07:12 -0800 Subject: [PATCH 25/34] doc wording --- docs/plugins/spotify.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 51537b456..1276749e0 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -7,7 +7,7 @@ any tracks that can be matched with a Spotify ID are returned, and the results can be either pasted in to a playlist or opened directly in the Spotify app. -Spotify URLs and IDs may also be provided in the ``Enter release ID:`` prompt +Spotify URLs and IDs may also be provided to the ``Enter release ID:`` prompt during ``beet import`` to autotag music with data from the Spotify `Album`_ and `Track`_ APIs. From f61aacf04bfa492df369b0fde6cb94aeea2af0ff Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 14:40:32 -0800 Subject: [PATCH 26/34] remove tokenfile doc, use active voice --- docs/plugins/spotify.rst | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 1276749e0..91bbb470a 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -2,14 +2,10 @@ Spotify Plugin ============== The ``spotify`` plugin generates `Spotify`_ playlists from tracks in your -library with the ``beet spotify`` command. Using the `Spotify Search API`_, -any tracks that can be matched with a Spotify ID are returned, and the -results can be either pasted in to a playlist or opened directly in the -Spotify app. +library with the ``beet spotify`` command using the `Spotify Search API`_. -Spotify URLs and IDs may also be provided to the ``Enter release ID:`` prompt -during ``beet import`` to autotag music with data from the Spotify -`Album`_ and `Track`_ APIs. +Also, the plugin can use the Spotify `Album`_ and `Track`_ APIs to provide +metadata matches for the importer. .. _Spotify: https://www.spotify.com/ .. _Spotify Search API: https://developer.spotify.com/documentation/web-api/reference/search/search/ @@ -58,23 +54,11 @@ Command-line options include: * ``--show-failures`` or ``-f``: List the tracks that did not match a Spotify ID. -A Spotify ID or URL may also be provided to the ``Enter release ID:`` +You can enter the URL for an album or song on Spotify at the ``enter Id`` prompt during import:: Enter search, enter Id, aBort, eDit, edit Candidates, plaY? i Enter release ID: https://open.spotify.com/album/2rFYTHFBLQN3AYlrymBPPA - Tagging: - Bear Hands - Blue Lips / Ignoring the Truth / Back Seat Driver (Spirit Guide) / 2AM - URL: - https://open.spotify.com/album/2rFYTHFBLQN3AYlrymBPPA - (Similarity: 88.2%) (source, tracks) (Spotify, 2019, Spensive Sounds) - * Blue Lips (feat. Ursula Rose) -> Blue Lips (feat. Ursula Rose) (source) - * Ignoring the Truth -> Ignoring the Truth (source) - * Back Seat Driver (Spirit Guide) -> Back Seat Driver (Spirit Guide) (source) - * 2AM -> 2AM (source) - [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, - Enter search, enter Id, aBort, eDit, edit Candidates, plaY? - Configuration ------------- @@ -106,9 +90,6 @@ in config.yaml under the ``spotify:`` section: track/album/artist fields before sending them to Spotify. Can be useful for changing certain abbreviations, like ft. -> feat. See the examples below. Default: None. -- **tokenfile**: Filename of the JSON file stored in the beets configuration - directory to use for caching the OAuth access token. - Default: ``spotify_token.json``. - **source_weight**: Penalty applied to Spotify matches during import. Set to 0.0 to disable. Default: ``0.5``. @@ -119,7 +100,6 @@ Here's an example:: client_id: N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN client_secret: 6DRS7k66h4643yQEbepPxOuxeVW0yZpk source_weight: 0.7 - tokenfile: my_spotify_token.json mode: open region_filter: US show_failures: on From b4d54b0950a82079563cbe7a6d71ea30b1a7ee71 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 15:00:32 -0800 Subject: [PATCH 27/34] set TrackInfo.index in track_for_id --- beetsplug/spotify.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 713c670f0..e7bf575ed 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -229,14 +229,28 @@ class SpotifyPlugin(BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - spotify_id = self._get_spotify_id('track', track_id) - if spotify_id is None: + spotify_id_track = self._get_spotify_id('track', track_id) + if spotify_id_track is None: return None - response = self._handle_response( - requests.get, self.track_url + spotify_id + response_track = self._handle_response( + requests.get, self.track_url + spotify_id_track ) - return self._get_track(response.json()) + response_data_track = response_track.json() + track = self._get_track(response_data_track) + + # get album tracks set index/position on entire release + spotify_id_album = response_track['album']['id'] + response_album = self._handle_response( + requests.get, self.album_url + spotify_id_album + ) + response_data_album = response_album.json() + for i, track_data in enumerate(response_data_album['tracks']['items']): + if track_data['id'] == spotify_id_track: + track.index = i + 1 + break + + return track def _get_artist(self, artists): """Returns an artist string (all artists) and an artist_id (the main From 78a46fd4d02e4df9719a73aae28f92230610ba39 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 15:02:19 -0800 Subject: [PATCH 28/34] doc typo --- beetsplug/spotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index e7bf575ed..f541a873b 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -239,7 +239,7 @@ class SpotifyPlugin(BeetsPlugin): response_data_track = response_track.json() track = self._get_track(response_data_track) - # get album tracks set index/position on entire release + # get album's tracks to set the track's index/position on entire release spotify_id_album = response_track['album']['id'] response_album = self._handle_response( requests.get, self.album_url + spotify_id_album From dbf17f760e6995d2b9ea892b07b0da2045359a33 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 15:09:51 -0800 Subject: [PATCH 29/34] add TrackInfo.medium --- beetsplug/spotify.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index f541a873b..83f3788d8 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -215,6 +215,7 @@ class SpotifyPlugin(BeetsPlugin): artist_id=artist_id, length=track_data['duration_ms'] / 1000, index=track_data['track_number'], + medium=track_data['disc_number'], medium_index=track_data['track_number'], data_source='Spotify', data_url=track_data['external_urls']['spotify'], From 844b940832abcb3e16b2c4984a81d8c10ed05128 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 15:32:07 -0800 Subject: [PATCH 30/34] capture TrackInfo.medium_total --- beetsplug/spotify.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 83f3788d8..fadedb39c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -6,6 +6,7 @@ import re import json import base64 import webbrowser +import collections import requests @@ -176,10 +177,14 @@ class SpotifyPlugin(BeetsPlugin): ) tracks = [] + medium_totals = collections.defaultdict(int) for i, track_data in enumerate(response_data['tracks']['items']): track = self._get_track(track_data) track.index = i + 1 + medium_totals[track.medium] += 1 tracks.append(track) + for track in tracks: + track.medium_total = medium_totals[track.medium] return AlbumInfo( album=response_data['name'], @@ -240,17 +245,20 @@ class SpotifyPlugin(BeetsPlugin): response_data_track = response_track.json() track = self._get_track(response_data_track) - # get album's tracks to set the track's index/position on entire release + # get album's tracks to set the track's index/position on + # the entire release spotify_id_album = response_track['album']['id'] response_album = self._handle_response( requests.get, self.album_url + spotify_id_album ) response_data_album = response_album.json() + medium_total = 0 for i, track_data in enumerate(response_data_album['tracks']['items']): - if track_data['id'] == spotify_id_track: - track.index = i + 1 - break - + if track_data['disc_number'] == track.medium: + medium_total += 1 + if track_data['id'] == spotify_id_track: + track.index = i + 1 + track.medium_total = medium_total return track def _get_artist(self, artists): From 415b21cbc1f9b9bcd15c0dd124905bf912d491ef Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 01:30:37 -0800 Subject: [PATCH 31/34] fix var reference, add docstring --- beetsplug/spotify.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index fadedb39c..0626ca937 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -145,6 +145,11 @@ class SpotifyPlugin(BeetsPlugin): def album_for_id(self, album_id): """Fetches an album by its Spotify ID or URL and returns an AlbumInfo object or None if the album is not found. + + :param album_id: Spotify ID or URL for the album + :type album_id: str + :return: AlbumInfo object for album + :rtype: beets.autotag.hooks.AlbumInfo """ spotify_id = self._get_spotify_id('album', album_id) if spotify_id is None: @@ -247,9 +252,8 @@ class SpotifyPlugin(BeetsPlugin): # get album's tracks to set the track's index/position on # the entire release - spotify_id_album = response_track['album']['id'] response_album = self._handle_response( - requests.get, self.album_url + spotify_id_album + requests.get, self.album_url + response_data_track['album']['id'] ) response_data_album = response_album.json() medium_total = 0 @@ -261,7 +265,8 @@ class SpotifyPlugin(BeetsPlugin): track.medium_total = medium_total return track - def _get_artist(self, artists): + @staticmethod + def _get_artist(artists): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of Spotify artist object dicts. From cb8b0874d41a631d974a11ed4ef9cb7b9291a642 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 01:56:57 -0800 Subject: [PATCH 32/34] naming --- beetsplug/spotify.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 0626ca937..2d044cb47 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -240,12 +240,12 @@ class SpotifyPlugin(BeetsPlugin): :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ - spotify_id_track = self._get_spotify_id('track', track_id) - if spotify_id_track is None: + spotify_id = self._get_spotify_id('track', track_id) + if spotify_id is None: return None response_track = self._handle_response( - requests.get, self.track_url + spotify_id_track + requests.get, self.track_url + spotify_id ) response_data_track = response_track.json() track = self._get_track(response_data_track) @@ -260,7 +260,7 @@ class SpotifyPlugin(BeetsPlugin): for i, track_data in enumerate(response_data_album['tracks']['items']): if track_data['disc_number'] == track.medium: medium_total += 1 - if track_data['id'] == spotify_id_track: + if track_data['id'] == spotify_id: track.index = i + 1 track.medium_total = medium_total return track From b50e148becfe189b1e2721398a343a2ef60b483d Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 08:32:57 -0800 Subject: [PATCH 33/34] use official client ID/secret, remove usage from docs --- beetsplug/spotify.py | 4 ++-- docs/plugins/spotify.rst | 17 ++--------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 2d044cb47..dd0e936be 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -38,8 +38,8 @@ class SpotifyPlugin(BeetsPlugin): 'track_field': 'title', 'region_filter': None, 'regex': [], - 'client_id': 'N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN', - 'client_secret': '6DRS7k66h4643yQEbepPxOuxeVW0yZpk', + 'client_id': '4e414367a1d14c75a5c5129a627fcab8', + 'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc', 'tokenfile': 'spotify_token.json', 'source_weight': 0.5, } diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index 91bbb470a..3f4c6c43d 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -22,19 +22,8 @@ Why Use This Plugin? Basic Usage ----------- - -First, register a `Spotify application`_ to use with beets and add your Client ID -and Client Secret to your :doc:`configuration file ` under a -``spotify`` section:: - - spotify: - client_id: N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN - client_secret: 6DRS7k66h4643yQEbepPxOuxeVW0yZpk - -.. _Spotify application: https://developer.spotify.com/documentation/general/guides/app-settings/ - -Then, enable the ``spotify`` plugin (see :ref:`using-plugins`) and use the ``spotify`` -command with a beets query:: +First, enable the ``spotify`` plugin (see :ref:`using-plugins`). +Then, use the ``spotify`` command with a beets query:: beet spotify [OPTIONS...] QUERY @@ -97,8 +86,6 @@ in config.yaml under the ``spotify:`` section: Here's an example:: spotify: - client_id: N3dliiOOTBEEFqCH5NDDUmF5Eo8bl7AN - client_secret: 6DRS7k66h4643yQEbepPxOuxeVW0yZpk source_weight: 0.7 mode: open region_filter: US From dab62f2194a5a1d9127f363618aafa4f8c510c13 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 09:23:38 -0800 Subject: [PATCH 34/34] inline auth_header property --- beetsplug/spotify.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dd0e936be..246e65a6f 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -95,10 +95,6 @@ class SpotifyPlugin(BeetsPlugin): with open(self.tokenfile, 'w') as f: json.dump({'access_token': self.access_token}, f) - @property - def _auth_header(self): - return {'Authorization': 'Bearer {}'.format(self.access_token)} - def _handle_response(self, request_type, url, params=None): """Send a request, reauthenticating if necessary. @@ -113,7 +109,11 @@ class SpotifyPlugin(BeetsPlugin): :return: class:`Response ` object :rtype: requests.Response """ - response = request_type(url, headers=self._auth_header, params=params) + response = request_type( + url, + headers={'Authorization': 'Bearer {}'.format(self.access_token)}, + params=params, + ) if response.status_code != 200: if u'token expired' in response.text: self._log.debug(