From ce4b45ec764ef5ac9d0e8ab736b3c7a75e4803b8 Mon Sep 17 00:00:00 2001 From: Olin Gay Date: Sun, 3 Aug 2014 19:55:50 -0400 Subject: [PATCH 1/3] Pull request for Spotify plugin development, see: https://groups.google.com/forum/#!topic/beets-users/2xsOZC-NMNk --- beetsplug/spotify.py | 163 +++++++++++++++++++++++++++++++++++++++ docs/plugins/index.rst | 2 + docs/plugins/spotify.rst | 105 +++++++++++++++++++++++++ test/test_spotify.py | 152 ++++++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 beetsplug/spotify.py create mode 100644 docs/plugins/spotify.rst create mode 100644 test/test_spotify.py diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py new file mode 100644 index 000000000..920f17af5 --- /dev/null +++ b/beetsplug/spotify.py @@ -0,0 +1,163 @@ +import json, re, webbrowser +import requests +from pprint import pprint +from operator import attrgetter +from beets.plugins import BeetsPlugin +from beets.ui import decargs +from beets import config, ui, library +from requests.exceptions import HTTPError + +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:" + + def __init__(self): + super(SpotifyPlugin, self).__init__() + self.config.add({ + 'mode': 'list', + 'tiebreak' : 'popularity', + 'show_failures': False, + 'verbose': False, + 'artist_field': 'albumartist', + 'album_field': 'album', + 'track_field': 'title', + 'region_filter': None, + 'regex': [] + }) + + def commands(self): + def queries(lib, opts, args): + success = self.parse_opts(opts) + if success: + results = self.query_spotify(lib, decargs(args)) + self.output_results(results) + spotify_cmd = ui.Subcommand('spotify', + help='build spotify playlist of results' + ) + spotify_cmd.parser.add_option('-m', '--mode', action='store', + help='"open" to open spotify with playlist, "list" to copy/paste (default)' + ) + spotify_cmd.parser.add_option('-f', '--show_failures', action='store_true', + help='Print out list of any tracks that did not match a Sptoify ID' + ) + spotify_cmd.parser.add_option('-v', '--verbose', action='store_true', + help='show extra output' + ) + spotify_cmd.func = queries + return [spotify_cmd] + + def parse_opts(self, opts): + if opts.mode: + self.config['mode'].set(opts.mode) + + if opts.show_failures: + self.config['show_failures'].set(True) + + if self.config['mode'].get() not in ['list', 'open']: + print self.config['mode'].get() + " is not a valid mode" + return False + + self.opts = opts + return True + + def query_spotify(self, lib, query): + + results = [] + failures = [] + + items = lib.items(query) + + if not items: + self.out("Your beets query returned no items, skipping spotify") + return + + print "Processing " + str(len(items)) + " tracks..." + + for item in items: + + # Apply regex transformations if provided + for regex in self.config['regex'].get(): + 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) + + # Custom values can be passed in the config (just in case) + 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 + + # Query the Web API for each track and look for the items' JSON data + r = requests.get(self.base_url, params={"q": search_url, "type": "track"}) + self.out(r.url) + try: + r.raise_for_status() + except HTTPError as e: + self.out("URL returned a " + e.response.status_code + "error") + failures.append(search_url) + continue + + r_data = r.json()['tracks']['items'] + + # Apply market filter if requested + region_filter = self.config['region_filter'].get() + if region_filter: + r_data = filter(lambda x: region_filter in x['available_markets'], r_data) + + # Simplest, take the first result + chosen_result = None + if len(r_data) == 1 or self.config['tiebreak'].get() == "first": + self.out("Spotify track(s) found, count: " + str(len(r_data))) + chosen_result = r_data[0] + elif len(r_data) > 1: + # Use the popularity filter + self.out("Most popular track chosen, count: " + str(len(r_data))) + chosen_result = max(r_data, key=lambda x: x['popularity']) + + if chosen_result: + results.append(chosen_result) + else: + self.out("No spotify track found: " + search_url) + failures.append(search_url) + + failure_count = len(failures) + if failure_count > 0: + if self.config['show_failures'].get(): + print + print str(failure_count) + " track(s) did not match a Spotify ID" + print "#########################" + for track in failures: + print "track:" + track + print "#########################" + else: + print str(failure_count) + " track(s) did not match a Spotify ID, --show_failures to display" + + return results + + def output_results(self, results): + if results: + ids = map(lambda x: x['id'], results) + if self.config['mode'].get() == "open": + print "Attempting to open Spotify with playlist" + spotify_url = self.playlist_partial + ",".join(ids) + webbrowser.open(spotify_url) + + else: + print + print "Copy everything between the hashes and paste into a Spotify playlist" + print "#########################" + for item in ids: + print unicode.encode(self.open_url + item) + print "#########################" + else: + print "No Spotify tracks found from beets query" + + def out(self, msg): + if self.config['verbose'].get() or self.opts.verbose: + print msg; + diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 613c9408d..cdf802ba0 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -61,6 +61,7 @@ by typing ``beet version``. bucket importadded bpm + spotify Autotagger Extensions --------------------- @@ -135,6 +136,7 @@ Miscellaneous * :doc:`info`: Print music files' tags to the console. * :doc:`missing`: List missing tracks. * :doc:`duplicates`: List duplicate tracks or albums. +* :doc:`spotify`: Create Spotify playlists from the Beets library .. _MPD: http://www.musicpd.org/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst new file mode 100644 index 000000000..b383cd778 --- /dev/null +++ b/docs/plugins/spotify.rst @@ -0,0 +1,105 @@ +Spotify Plugin +===================== + +The ``spotify`` plugin generates Spotify playlists from tracks within the Beets library. Using the `Spotify Web API`_, any tracks that can be matched with a Spotify ID are returned, and the results can be either copy/pasted in to a playlist, or opened directly in Spotify. + +.. _Spotify Web API: https://developer.spotify.com/web-api/search-item/ + +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 + +Basic Usage +----------- +First, enable the plugin in your beets configuration:: + + plugins: spotify + +Then, you can search for tracks as usual with the ``spotify`` command:: + + beet spotify + + +An example command, and it's output:: + + beet spotify "In The Lonely Hour" + + Processing 14 tracks... + + Copy everything between the hashes and paste into a Spotify playlist + ######################### + http://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4 + http://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS + http://open.spotify.com/track/0ci6bxPw8muHTmSRs1MOjD + http://open.spotify.com/track/7IHOIqZUUInxjVkko181PB + http://open.spotify.com/track/0fySyVgczjbjbxMwNrdwkp + http://open.spotify.com/track/1VbhR6D6zUoSTBzvnRonXO + http://open.spotify.com/track/4TBFEe4n95WPxeUYt9jrMe + http://open.spotify.com/track/2DnBQKrh8aodN4dBSdXAUh + http://open.spotify.com/track/4DlYkz7xtje3iV2dDBu3OK + http://open.spotify.com/track/7C6cA8girfd6ZvbkmZmx9V + http://open.spotify.com/track/6uZ2x1Z6DSpOGAlVlvuhif + http://open.spotify.com/track/3aoAkxvRjwhXDajp5aSZS6 + http://open.spotify.com/track/7cG68oOj0pZYoSVuP1Jzot + http://open.spotify.com/track/4qPtIDBT2iVQv13tjpXMDt + ######################### + +Options for the command:: + + Usage: beet spotify [options] + + Options: + -h, --help show this help message and exit + -m MODE, --mode=MODE "open" to open spotify with playlist, "list" to + copy/paste (default) + -f, --show_failures Print out list of any tracks that did not match a + Sptoify ID + -v, --verbose show extra output + +Configuring +----------- + +The default options should work as-is, but there are some options you can put in config.yaml: + +* ``mode``: See the section below on modes +* ``region_filter``: Use the 2-character country abbreviation to limit results to that market +* ``show_failures``: Show the artist/album/track for each lookup that does not return a Spotify ID (and therefore cannot be added to a playlist) +* ``tiebreak``: How to choose the track if there is more than one identical result. For example, there might be multiple releases of the same album. Currently, this defaults to "popularity", "first" simply chooses the first in the list returned by Spotify. +* ``regex``: An array of regex transformations to perform on the track/album/artist fields before sending them to Spotify. Can be useful for changing certain abbreviations, like ft. -> feat. See the examples below. +* ``artist_field`` / ``album_field`` / ``track_field``: These allow the user to choose a different field to send to Spotify when looking up the track, album and artist. Most users will not want to change this. + +Example config.yaml +------------------- + +Examples of the configuration options:: + + spotify: + mode: "open" # Default is list, shows the copy/paste output. open attempts to open directly in Spotify (only tested on Mac) + region_filter: "US" # Filters tracks by only that market (2-letter code) + show_faiulres: on # Displays the tracks that did not match a Spotify ID + tiebreak: "first" # Need to break ties when then are multiple tracks. Default is popularity. + artist_field: "albumartist" # Which beets field to use for the artist lookup + album_field: "album" # Which beets field to use for the album lookup + track_field: "title" # Which beets field to use for the track lookup + regex: [ + { + field: "albumartist", # Field in the item object to regex + search: "Something", # String to look for + replace: "Replaced" # Replacement value + }, + { + field: "title", + search: "Something Else", + replace: "AlsoReplaced" + } + ] + +Spotify Plugin Modes +--------------------- + +* ``list``: The default mode for the spotify plugin is to print out the playlist as a list of links. This list can then be copied and pasted in to a new or existing spotify playlist. +* ``open``: This mode actually sends a link to your default webbrowser with instructions to open spotify with the playlist you created. Until this has been tested on all platforms, it will remain optional. + diff --git a/test/test_spotify.py b/test/test_spotify.py new file mode 100644 index 000000000..901adc132 --- /dev/null +++ b/test/test_spotify.py @@ -0,0 +1,152 @@ +"""Tests for the 'spotify' plugin""" + +from _common import unittest +import _common +from beets import config +from beets.library import Item +from beetsplug import spotify +from helper import TestHelper +import requests, responses +from requests.exceptions import HTTPError + +class ArgumentsMock(object): + def __init__(self, mode, show_failures): + self.mode = mode + self.show_failures = show_failures + self.verbose = True + +class SpotifyPluginTest(_common.TestCase, TestHelper): + + def setUp(self): + config.clear() + self.setup_beets() + self.spotify = spotify.SpotifyPlugin() + opts = ArgumentsMock("list", False) + self.spotify.parse_opts(opts) + + def tearDown(self): + self.teardown_beets() + + def test_args(self): + opts = ArgumentsMock("fail", True) + self.assertEqual(False, self.spotify.parse_opts(opts)) + 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, "1=2")) + + def test_missing_request(self): + response_body = """{ + "tracks" : { + "href" : "https://api.spotify.com/v1/search?query=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&offset=0&limit=20&type=track", + "items" : [ ], + "limit" : 20, + "next" : null, + "offset" : 0, + "previous" : null, + "total" : 0 + } + }""" + responses.add(responses.GET, + 'https://api.spotify.com/v1/search?q=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&type=track', + body=response_body, status=200, + content_type='application/json') + item = Item( + mb_trackid='01234', + album='lkajsdflakjsd', + albumartist='ujydfsuihse', + title='duifhjslkef', + length=10 + ) + item.add(self.lib) + self.assertEquals([], self.spotify.query_spotify(self.lib, "")) + + def test_track_request(self): + response_body = """{ + "tracks" : { + "href" : "https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", + "items" : [ { + "album" : { + "album_type" : "compilation", + "available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], + "external_urls" : { + "spotify" : "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" + }, + "href" : "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", + "id" : "5l3zEmMrOhOzG8d8s83GOL", + "images" : [ { + "height" : 640, + "url" : "https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8", + "width" : 640 + }, { + "height" : 300, + "url" : "https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb", + "width" : 300 + }, { + "height" : 64, + "url" : "https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8", + "width" : 64 + } ], + "name" : "Despicable Me 2 (Original Motion Picture Soundtrack)", + "type" : "album", + "uri" : "spotify:album:5l3zEmMrOhOzG8d8s83GOL" + }, + "artists" : [ { + "external_urls" : { + "spotify" : "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" + }, + "href" : "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", + "id" : "2RdwBSPQiwcmiDo9kixcl8", + "name" : "Pharrell Williams", + "type" : "artist", + "uri" : "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" + } ], + "available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], + "disc_number" : 1, + "duration_ms" : 233305, + "explicit" : false, + "external_ids" : { + "isrc" : "USQ4E1300686" + }, + "external_urls" : { + "spotify" : "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" + }, + "href" : "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", + "id" : "6NPVjNh8Jhru9xOmyQigds", + "name" : "Happy", + "popularity" : 89, + "preview_url" : "https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", + "track_number" : 4, + "type" : "track", + "uri" : "spotify:track:6NPVjNh8Jhru9xOmyQigds" + } ], + "limit" : 20, + "next" : null, + "offset" : 0, + "previous" : null, + "total" : 1 + } + }""" + responses.add(responses.GET, + 'https://api.spotify.com/v1/search?q=Happy+album%3ADespicable%20Me%202+artist%3APharrell%20Williams&type=track', + body=response_body, status=200, + content_type='application/json') + item = Item( + mb_trackid='01234', + album='Despicable Me 2', + albumartist='Pharrell Williams', + title='Happy', + length=10 + ) + item.add(self.lib) + results = self.spotify.query_spotify(self.lib, "Happy") + self.assertEquals(1, len(results)) + self.assertEquals("6NPVjNh8Jhru9xOmyQigds", results[0]['id']) + self.spotify.output_results(results) + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') From d076e145158ed63846d488436de5f094e4c21a37 Mon Sep 17 00:00:00 2001 From: Olin Gay Date: Sun, 17 Aug 2014 09:53:54 -0400 Subject: [PATCH 2/3] Complete PEP8 cleanup of spotify plugin --- beetsplug/spotify.py | 66 ++++++++----- test/test_spotify.py | 217 +++++++++++++++++++++++++------------------ 2 files changed, 171 insertions(+), 112 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 920f17af5..9fd52dd4c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -1,12 +1,12 @@ -import json, re, webbrowser +import re +import webbrowser import requests -from pprint import pprint -from operator import attrgetter from beets.plugins import BeetsPlugin from beets.ui import decargs -from beets import config, ui, library +from beets import ui from requests.exceptions import HTTPError + class SpotifyPlugin(BeetsPlugin): # URL for the Web API of Spotify @@ -19,7 +19,7 @@ class SpotifyPlugin(BeetsPlugin): super(SpotifyPlugin, self).__init__() self.config.add({ 'mode': 'list', - 'tiebreak' : 'popularity', + 'tiebreak': 'popularity', 'show_failures': False, 'verbose': False, 'artist_field': 'albumartist', @@ -35,16 +35,21 @@ class SpotifyPlugin(BeetsPlugin): if success: results = self.query_spotify(lib, decargs(args)) self.output_results(results) - spotify_cmd = ui.Subcommand('spotify', + spotify_cmd = ui.Subcommand( + 'spotify', help='build spotify playlist of results' ) - spotify_cmd.parser.add_option('-m', '--mode', action='store', - help='"open" to open spotify with playlist, "list" to copy/paste (default)' + spotify_cmd.parser.add_option( + '-m', '--mode', action='store', + help='"open" to open spotify with playlist, ' + '"list" to copy/paste (default)' ) - spotify_cmd.parser.add_option('-f', '--show_failures', action='store_true', + spotify_cmd.parser.add_option( + '-f', '--show_failures', action='store_true', help='Print out list of any tracks that did not match a Sptoify ID' ) - spotify_cmd.parser.add_option('-v', '--verbose', action='store_true', + spotify_cmd.parser.add_option( + '-v', '--verbose', action='store_true', help='show extra output' ) spotify_cmd.func = queries @@ -81,10 +86,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()] @@ -92,8 +104,10 @@ class SpotifyPlugin(BeetsPlugin): query = item[self.config['track_field'].get()] search_url = query + " album:" + album + " artist:" + artist - # Query the Web API for each track and look for the items' JSON data - r = requests.get(self.base_url, params={"q": search_url, "type": "track"}) + # 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" + }) self.out(r.url) try: r.raise_for_status() @@ -103,12 +117,14 @@ class SpotifyPlugin(BeetsPlugin): continue r_data = r.json()['tracks']['items'] - + # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: - r_data = filter(lambda x: region_filter in x['available_markets'], r_data) - + r_data = filter( + lambda x: region_filter in x['available_markets'], r_data + ) + # Simplest, take the first result chosen_result = None if len(r_data) == 1 or self.config['tiebreak'].get() == "first": @@ -116,7 +132,9 @@ class SpotifyPlugin(BeetsPlugin): chosen_result = r_data[0] elif len(r_data) > 1: # Use the popularity filter - self.out("Most popular track chosen, count: " + str(len(r_data))) + self.out( + "Most popular track chosen, count: " + str(len(r_data)) + ) chosen_result = max(r_data, key=lambda x: x['popularity']) if chosen_result: @@ -129,13 +147,15 @@ class SpotifyPlugin(BeetsPlugin): if failure_count > 0: if self.config['show_failures'].get(): print - print str(failure_count) + " track(s) did not match a Spotify ID" + print str(failure_count) + \ + " track(s) did not match a Spotify ID" print "#########################" for track in failures: print "track:" + track print "#########################" else: - print str(failure_count) + " track(s) did not match a Spotify ID, --show_failures to display" + print str(failure_count) + " track(s) did not match " + \ + "a Spotify ID, --show_failures to display" return results @@ -149,7 +169,8 @@ class SpotifyPlugin(BeetsPlugin): else: print - print "Copy everything between the hashes and paste into a Spotify playlist" + print "Copy everything between the hashes and paste into " + \ + "a Spotify playlist" print "#########################" for item in ids: print unicode.encode(self.open_url + item) @@ -159,5 +180,4 @@ class SpotifyPlugin(BeetsPlugin): def out(self, msg): if self.config['verbose'].get() or self.opts.verbose: - print msg; - + print msg diff --git a/test/test_spotify.py b/test/test_spotify.py index 901adc132..9e5cc9cae 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -1,19 +1,20 @@ """Tests for the 'spotify' plugin""" -from _common import unittest import _common +import responses +from _common import unittest from beets import config from beets.library import Item from beetsplug import spotify from helper import TestHelper -import requests, responses -from requests.exceptions import HTTPError + class ArgumentsMock(object): - def __init__(self, mode, show_failures): - self.mode = mode - self.show_failures = show_failures - self.verbose = True + def __init__(self, mode, show_failures): + self.mode = mode + self.show_failures = show_failures + self.verbose = True + class SpotifyPluginTest(_common.TestCase, TestHelper): @@ -37,21 +38,25 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.assertEqual(None, self.spotify.query_spotify(self.lib, "1=2")) def test_missing_request(self): - response_body = """{ - "tracks" : { - "href" : "https://api.spotify.com/v1/search?query=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&offset=0&limit=20&type=track", - "items" : [ ], - "limit" : 20, - "next" : null, - "offset" : 0, - "previous" : null, - "total" : 0 - } - }""" + response_body = str( + '{' + '"tracks" : {' + '"href" : "https://api.spotify.com/v1/search?query=duifhjslkef' + '+album%3Alkajsdflakjsd+artist%3A&offset=0&limit=20&type=track",' + '"items" : [ ],' + '"limit" : 20,' + '"next" : null,' + '"offset" : 0,' + '"previous" : null,' + '"total" : 0' + '}' + '}' + ) responses.add(responses.GET, - 'https://api.spotify.com/v1/search?q=duifhjslkef+album%3Alkajsdflakjsd+artist%3A&type=track', - body=response_body, status=200, - content_type='application/json') + 'https://api.spotify.com/v1/search?q=duifhjslkef+album' + '%3Alkajsdflakjsd+artist%3A&type=track', + body=response_body, status=200, + content_type='application/json') item = Item( mb_trackid='01234', album='lkajsdflakjsd', @@ -63,75 +68,108 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.assertEquals([], self.spotify.query_spotify(self.lib, "")) def test_track_request(self): - response_body = """{ - "tracks" : { - "href" : "https://api.spotify.com/v1/search?query=Happy+album%3ADespicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20&type=track", - "items" : [ { - "album" : { - "album_type" : "compilation", - "available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], - "external_urls" : { - "spotify" : "https://open.spotify.com/album/5l3zEmMrOhOzG8d8s83GOL" - }, - "href" : "https://api.spotify.com/v1/albums/5l3zEmMrOhOzG8d8s83GOL", - "id" : "5l3zEmMrOhOzG8d8s83GOL", - "images" : [ { - "height" : 640, - "url" : "https://i.scdn.co/image/cb7905340c132365bbaee3f17498f062858382e8", - "width" : 640 - }, { - "height" : 300, - "url" : "https://i.scdn.co/image/af369120f0b20099d6784ab31c88256113f10ffb", - "width" : 300 - }, { - "height" : 64, - "url" : "https://i.scdn.co/image/9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8", - "width" : 64 - } ], - "name" : "Despicable Me 2 (Original Motion Picture Soundtrack)", - "type" : "album", - "uri" : "spotify:album:5l3zEmMrOhOzG8d8s83GOL" - }, - "artists" : [ { - "external_urls" : { - "spotify" : "https://open.spotify.com/artist/2RdwBSPQiwcmiDo9kixcl8" - }, - "href" : "https://api.spotify.com/v1/artists/2RdwBSPQiwcmiDo9kixcl8", - "id" : "2RdwBSPQiwcmiDo9kixcl8", - "name" : "Pharrell Williams", - "type" : "artist", - "uri" : "spotify:artist:2RdwBSPQiwcmiDo9kixcl8" - } ], - "available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG", "BO", "BR", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "EC", "EE", "ES", "FI", "FR", "GB", "GR", "GT", "HK", "HN", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "MT", "MX", "MY", "NI", "NL", "NO", "NZ", "PA", "PE", "PH", "PL", "PT", "PY", "RO", "SE", "SG", "SI", "SK", "SV", "TR", "TW", "US", "UY" ], - "disc_number" : 1, - "duration_ms" : 233305, - "explicit" : false, - "external_ids" : { - "isrc" : "USQ4E1300686" - }, - "external_urls" : { - "spotify" : "https://open.spotify.com/track/6NPVjNh8Jhru9xOmyQigds" - }, - "href" : "https://api.spotify.com/v1/tracks/6NPVjNh8Jhru9xOmyQigds", - "id" : "6NPVjNh8Jhru9xOmyQigds", - "name" : "Happy", - "popularity" : 89, - "preview_url" : "https://p.scdn.co/mp3-preview/6b00000be293e6b25f61c33e206a0c522b5cbc87", - "track_number" : 4, - "type" : "track", - "uri" : "spotify:track:6NPVjNh8Jhru9xOmyQigds" - } ], - "limit" : 20, - "next" : null, - "offset" : 0, - "previous" : null, - "total" : 1 - } - }""" + response_body = str( + '{' + '"tracks" : {' + '"href" : "https://api.spotify.com/v1/search?query=Happy+album%3A' + 'Despicable+Me+2+artist%3APharrell+Williams&offset=0&limit=20' + '&type=track",' + '"items" : [ {' + '"album" : {' + '"album_type" : "compilation",' + '"available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG",' + '"BO", "BR", "CA", "CH", "CL", "CO",' + '"CR", "CY", "CZ", "DE", "DK", "DO",' + '"EC", "EE", "ES", "FI", "FR", "GB",' + '"GR", "GT", "HK", "HN", "HU", "IE",' + '"IS", "IT", "LI", "LT", "LU", "LV",' + '"MC", "MT", "MX", "MY", "NI", "NL",' + '"NO", "NZ", "PA", "PE", "PH", "PL",' + '"PT", "PY", "RO", "SE", "SG", "SI",' + '"SK", "SV", "TR", "TW", "US", "UY" ],' + '"external_urls" : {' + '"spotify" : "https://open.spotify.com/album/' + '5l3zEmMrOhOzG8d8s83GOL"' + '},' + '"href" : "https://api.spotify.com/v1/albums/' + '5l3zEmMrOhOzG8d8s83GOL",' + '"id" : "5l3zEmMrOhOzG8d8s83GOL",' + '"images" : [ {' + '"height" : 640,' + '"url" : "https://i.scdn.co/image/cb7905340c132365bb' + 'aee3f17498f062858382e8",' + '"width" : 640' + '}, {' + '"height" : 300,' + '"url" : "https://i.scdn.co/image/af369120f0b20099' + 'd6784ab31c88256113f10ffb",' + '"width" : 300' + '}, {' + '"height" : 64,' + '"url" : "https://i.scdn.co/image/' + '9dad385ddf2e7db0bef20cec1fcbdb08689d9ae8",' + '"width" : 64' + '} ],' + '"name" : "Despicable Me 2 (Original Motion Picture Soundtrack)",' + '"type" : "album",' + '"uri" : "spotify:album:5l3zEmMrOhOzG8d8s83GOL"' + '},' + '"artists" : [ {' + '"external_urls" : {' + '"spotify" : "https://open.spotify.com/artist/' + '2RdwBSPQiwcmiDo9kixcl8"' + '},' + '"href" : "https://api.spotify.com/v1/artists/' + '2RdwBSPQiwcmiDo9kixcl8",' + '"id" : "2RdwBSPQiwcmiDo9kixcl8",' + '"name" : "Pharrell Williams",' + '"type" : "artist",' + '"uri" : "spotify:artist:2RdwBSPQiwcmiDo9kixcl8"' + '} ],' + '"available_markets" : [ "AD", "AR", "AT", "AU", "BE", "BG", "BO",' + '"BR", "CA", "CH", "CL", "CO", "CR", "CY",' + '"CZ", "DE", "DK", "DO", "EC", "EE", "ES",' + '"FI", "FR", "GB", "GR", "GT", "HK", "HN",' + '"HU", "IE", "IS", "IT", "LI", "LT", "LU",' + '"LV", "MC", "MT", "MX", "MY", "NI", "NL",' + '"NO", "NZ", "PA", "PE", "PH", "PL", "PT",' + '"PY", "RO", "SE", "SG", "SI", "SK", "SV",' + '"TR", "TW", "US", "UY" ],' + '"disc_number" : 1,' + '"duration_ms" : 233305,' + '"explicit" : false,' + '"external_ids" : {' + '"isrc" : "USQ4E1300686"' + '},' + '"external_urls" : {' + '"spotify" : "https://open.spotify.com/track/' + '6NPVjNh8Jhru9xOmyQigds"' + '},' + '"href" : "https://api.spotify.com/v1/tracks/' + '6NPVjNh8Jhru9xOmyQigds",' + '"id" : "6NPVjNh8Jhru9xOmyQigds",' + '"name" : "Happy",' + '"popularity" : 89,' + '"preview_url" : "https://p.scdn.co/mp3-preview/' + '6b00000be293e6b25f61c33e206a0c522b5cbc87",' + '"track_number" : 4,' + '"type" : "track",' + '"uri" : "spotify:track:6NPVjNh8Jhru9xOmyQigds"' + '} ],' + '"limit" : 20,' + '"next" : null,' + '"offset" : 0,' + '"previous" : null,' + '"total" : 1' + '}' + '}' + ) responses.add(responses.GET, - 'https://api.spotify.com/v1/search?q=Happy+album%3ADespicable%20Me%202+artist%3APharrell%20Williams&type=track', - body=response_body, status=200, - content_type='application/json') + 'https://api.spotify.com/v1/search?q=Happy+album%3A' + 'Despicable%20Me%202+artist%3APharrell%20' + 'Williams&type=track', + body=response_body, status=200, + content_type='application/json') item = Item( mb_trackid='01234', album='Despicable Me 2', @@ -145,6 +183,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.assertEquals("6NPVjNh8Jhru9xOmyQigds", results[0]['id']) self.spotify.output_results(results) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 9330e490139bc7b2b51f23f6a5ef3460a82a894c Mon Sep 17 00:00:00 2001 From: Olin Gay Date: Sun, 17 Aug 2014 10:36:41 -0400 Subject: [PATCH 3/3] Change spottily plugin output to use syserr for everything except printing the plugin tracks. Allows output to be piped. --- beetsplug/spotify.py | 41 +++++++++++++++++++++------------------- docs/plugins/spotify.rst | 11 ++++------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 9fd52dd4c..fa29943ba 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -1,3 +1,5 @@ +from __future__ import print_function +import sys import re import webbrowser import requests @@ -42,7 +44,7 @@ class SpotifyPlugin(BeetsPlugin): spotify_cmd.parser.add_option( '-m', '--mode', action='store', help='"open" to open spotify with playlist, ' - '"list" to copy/paste (default)' + '"list" to print (default)' ) spotify_cmd.parser.add_option( '-f', '--show_failures', action='store_true', @@ -63,7 +65,7 @@ class SpotifyPlugin(BeetsPlugin): self.config['show_failures'].set(True) if self.config['mode'].get() not in ['list', 'open']: - print self.config['mode'].get() + " is not a valid mode" + self.warning(self.config['mode'].get() + " is not a valid mode") return False self.opts = opts @@ -80,7 +82,7 @@ class SpotifyPlugin(BeetsPlugin): self.out("Your beets query returned no items, skipping spotify") return - print "Processing " + str(len(items)) + " tracks..." + self.warning("Processing " + str(len(items)) + " tracks...") for item in items: @@ -146,16 +148,17 @@ class SpotifyPlugin(BeetsPlugin): failure_count = len(failures) if failure_count > 0: if self.config['show_failures'].get(): - print - print str(failure_count) + \ - " track(s) did not match a Spotify ID" - print "#########################" + self.warning("\n#########################") + self.warning(str(failure_count) + + " track(s) did not match a Spotify ID") for track in failures: - print "track:" + track - print "#########################" + self.warning("track:" + track) + self.warning("#########################\n") else: - print str(failure_count) + " track(s) did not match " + \ + self.warning( + str(failure_count) + " track(s) did not match " "a Spotify ID, --show_failures to display" + ) return results @@ -163,21 +166,21 @@ class SpotifyPlugin(BeetsPlugin): if results: ids = map(lambda x: x['id'], results) if self.config['mode'].get() == "open": - print "Attempting to open Spotify with playlist" + self.warning("Attempting to open Spotify with playlist") spotify_url = self.playlist_partial + ",".join(ids) webbrowser.open(spotify_url) else: - print - print "Copy everything between the hashes and paste into " + \ - "a Spotify playlist" - print "#########################" + self.warning("") for item in ids: - print unicode.encode(self.open_url + item) - print "#########################" + print(unicode.encode(self.open_url + item)) + self.warning("") else: - print "No Spotify tracks found from beets query" + self.warning("No Spotify tracks found from beets query") def out(self, msg): if self.config['verbose'].get() or self.opts.verbose: - print msg + self.warning(msg) + + def warning(self, msg): + print(msg, file=sys.stderr) diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index b383cd778..d03d6618d 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -1,7 +1,7 @@ Spotify Plugin ===================== -The ``spotify`` plugin generates Spotify playlists from tracks within the Beets library. Using the `Spotify Web API`_, any tracks that can be matched with a Spotify ID are returned, and the results can be either copy/pasted in to a playlist, or opened directly in Spotify. +The ``spotify`` plugin generates Spotify playlists from tracks within the Beets 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 Spotify. .. _Spotify Web API: https://developer.spotify.com/web-api/search-item/ @@ -29,8 +29,6 @@ An example command, and it's output:: Processing 14 tracks... - Copy everything between the hashes and paste into a Spotify playlist - ######################### http://open.spotify.com/track/19w0OHr8SiZzRhjpnjctJ4 http://open.spotify.com/track/3PRLM4FzhplXfySa4B7bxS http://open.spotify.com/track/0ci6bxPw8muHTmSRs1MOjD @@ -45,7 +43,6 @@ An example command, and it's output:: http://open.spotify.com/track/3aoAkxvRjwhXDajp5aSZS6 http://open.spotify.com/track/7cG68oOj0pZYoSVuP1Jzot http://open.spotify.com/track/4qPtIDBT2iVQv13tjpXMDt - ######################### Options for the command:: @@ -54,7 +51,7 @@ Options for the command:: Options: -h, --help show this help message and exit -m MODE, --mode=MODE "open" to open spotify with playlist, "list" to - copy/paste (default) + print (default) -f, --show_failures Print out list of any tracks that did not match a Sptoify ID -v, --verbose show extra output @@ -77,7 +74,7 @@ Example config.yaml Examples of the configuration options:: spotify: - mode: "open" # Default is list, shows the copy/paste output. open attempts to open directly in Spotify (only tested on Mac) + mode: "open" # Default is list, shows the plugin output. open attempts to open directly in Spotify (only tested on Mac) region_filter: "US" # Filters tracks by only that market (2-letter code) show_faiulres: on # Displays the tracks that did not match a Spotify ID tiebreak: "first" # Need to break ties when then are multiple tracks. Default is popularity. @@ -100,6 +97,6 @@ Examples of the configuration options:: Spotify Plugin Modes --------------------- -* ``list``: The default mode for the spotify plugin is to print out the playlist as a list of links. This list can then be copied and pasted in to a new or existing spotify playlist. +* ``list``: The default mode for the spotify plugin is to print out the playlist as a list of links. This list can then be pasted in to a new or existing spotify playlist. * ``open``: This mode actually sends a link to your default webbrowser with instructions to open spotify with the playlist you created. Until this has been tested on all platforms, it will remain optional.