mirror of
https://github.com/beetbox/beets.git
synced 2025-12-15 13:07:09 +01:00
Merge pull request #892 from olinbg/spotify-plugin
Pull request for Spotify plugin development
This commit is contained in:
commit
3981a6bca0
4 changed files with 481 additions and 0 deletions
186
beetsplug/spotify.py
Normal file
186
beetsplug/spotify.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import print_function
|
||||
import sys
|
||||
import re
|
||||
import webbrowser
|
||||
import requests
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs
|
||||
from beets import ui
|
||||
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 print (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']:
|
||||
self.warning(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
|
||||
|
||||
self.warning("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, 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():
|
||||
self.warning("\n#########################")
|
||||
self.warning(str(failure_count) +
|
||||
" track(s) did not match a Spotify ID")
|
||||
for track in failures:
|
||||
self.warning("track:" + track)
|
||||
self.warning("#########################\n")
|
||||
else:
|
||||
self.warning(
|
||||
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":
|
||||
self.warning("Attempting to open Spotify with playlist")
|
||||
spotify_url = self.playlist_partial + ",".join(ids)
|
||||
webbrowser.open(spotify_url)
|
||||
|
||||
else:
|
||||
self.warning("")
|
||||
for item in ids:
|
||||
print(unicode.encode(self.open_url + item))
|
||||
self.warning("")
|
||||
else:
|
||||
self.warning("No Spotify tracks found from beets query")
|
||||
|
||||
def out(self, msg):
|
||||
if self.config['verbose'].get() or self.opts.verbose:
|
||||
self.warning(msg)
|
||||
|
||||
def warning(self, msg):
|
||||
print(msg, file=sys.stderr)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
102
docs/plugins/spotify.rst
Normal file
102
docs/plugins/spotify.rst
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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 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: <other plugins> spotify
|
||||
|
||||
Then, you can search for tracks as usual with the ``spotify`` command::
|
||||
|
||||
beet spotify <options if needed> <search args>
|
||||
|
||||
|
||||
An example command, and it's output::
|
||||
|
||||
beet spotify "In The Lonely Hour"
|
||||
|
||||
Processing 14 tracks...
|
||||
|
||||
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
|
||||
print (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 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.
|
||||
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 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.
|
||||
|
||||
191
test/test_spotify.py
Normal file
191
test/test_spotify.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Tests for the 'spotify' plugin"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 = 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')
|
||||
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 = 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%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',
|
||||
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')
|
||||
Loading…
Reference in a new issue