From d2521d92565e1d4765afbef552ec65a40fe5b7ec Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Wed, 19 Dec 2018 10:19:29 +0100 Subject: [PATCH 001/149] Fix acoustid_fingerprint type confusion Since pyacoustid returns the fingerprint as bytes (and thus causes the database to store a bytes/BLOB object), but the tag value is a string, the acoustid_fingerprint tag always causes file change when using beet's "write" command, even if the actual value didn't change. Issue #2942 describes the problem. This commit fixes that issue for newly imported/fingerprinted files. However, you still need to change the type of all acoustid_fingerprint fields that are already present in the database: $ sqlite3 beets.db SQLite version 3.26.0 2018-12-01 12:34:55 Enter ".help" for usage hints. sqlite> UPDATE items SET acoustid_fingerprint = CAST(acoustid_fingerprint AS TEXT); --- beetsplug/chroma.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 84e55985f..42abe09b5 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -93,6 +93,7 @@ def acoustid_match(log, path): log.error(u'fingerprinting of {0} failed: {1}', util.displayable_path(repr(path)), exc) return None + fp = fp.decode() _fingerprints[path] = fp try: res = acoustid.lookup(API_KEY, fp, duration, @@ -334,7 +335,7 @@ def fingerprint_item(log, item, write=False): util.displayable_path(item.path)) try: _, fp = acoustid.fingerprint_file(util.syspath(item.path)) - item.acoustid_fingerprint = fp + item.acoustid_fingerprint = fp.decode() if write: log.info(u'{0}: writing fingerprint', util.displayable_path(item.path)) From 21145731e4cf3784e0def5202820019135764c5e Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 20 Dec 2018 18:19:00 +0100 Subject: [PATCH 002/149] Fixed docstring typo. --- beetsplug/embedart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index afe8f86fa..71681f024 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -189,7 +189,7 @@ class EmbedCoverArtPlugin(BeetsPlugin): def remove_artfile(self, album): """Possibly delete the album art file for an album (if the - appropriate configuration option is enabled. + appropriate configuration option is enabled). """ if self.config['remove_art_file'] and album.artpath: if os.path.isfile(album.artpath): From 2ea77652a722db478b54d3d4e92be5e66d03eab3 Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 20 Dec 2018 18:19:30 +0100 Subject: [PATCH 003/149] Fetchart: Respect ignore and ignore_hidden settings when fetching art from the local filesystem. --- beetsplug/fetchart.py | 18 +++++++++++------- docs/changelog.rst | 1 + 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 673f56169..d7a885315 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -31,7 +31,7 @@ from beets import util from beets import config from beets.mediafile import image_mime_type from beets.util.artresizer import ArtResizer -from beets.util import confit +from beets.util import confit, sorted_walk from beets.util import syspath, bytestring_path, py3_path import six @@ -666,12 +666,16 @@ class FileSystem(LocalArtSource): # Find all files that look like images in the directory. images = [] - for fn in os.listdir(syspath(path)): - fn = bytestring_path(fn) - for ext in IMAGE_EXTENSIONS: - if fn.lower().endswith(b'.' + ext) and \ - os.path.isfile(syspath(os.path.join(path, fn))): - images.append(fn) + ignore = config['ignore'].as_str_seq() + ignore_hidden = config['ignore_hidden'].get(bool) + for _, _, files in sorted_walk(path, ignore=ignore, + ignore_hidden=ignore_hidden): + for fn in files: + fn = bytestring_path(fn) + for ext in IMAGE_EXTENSIONS: + if fn.lower().endswith(b'.' + ext) and \ + os.path.isfile(syspath(os.path.join(path, fn))): + images.append(fn) # Look for "preferred" filenames. images = sorted(images, diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e9b751fe..8273bddf6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -111,6 +111,7 @@ Fixes: * The ``%title`` template function now works correctly with apostrophes. Thanks to :user:`GuilhermeHideki`. :bug:`3033` +* Fetchart now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` .. _python-itunes: https://github.com/ocelma/python-itunes From 0696f915e5bcf48e0aa1836d9cba6174ac355214 Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 20 Dec 2018 18:37:34 +0100 Subject: [PATCH 004/149] test_fetchart: Avoid duplicate code in future tests. --- test/test_fetchart.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/test_fetchart.py b/test/test_fetchart.py index 91bc34101..a3c783766 100644 --- a/test/test_fetchart.py +++ b/test/test_fetchart.py @@ -29,21 +29,24 @@ class FetchartCliTest(unittest.TestCase, TestHelper): self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg' self.config['art_filename'] = 'mycover' self.album = self.add_album() + self.cover_path = os.path.join(self.album.path, b'mycover.jpg') def tearDown(self): self.unload_plugins() self.teardown_beets() + def check_cover_is_stored(self): + self.assertEqual(self.album['artpath'], self.cover_path) + with open(util.syspath(self.cover_path), 'r') as f: + self.assertEqual(f.read(), 'IMAGE') + def test_set_art_from_folder(self): self.touch(b'c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE') self.run_command('fetchart') - cover_path = os.path.join(self.album.path, b'mycover.jpg') self.album.load() - self.assertEqual(self.album['artpath'], cover_path) - with open(util.syspath(cover_path), 'r') as f: - self.assertEqual(f.read(), 'IMAGE') + self.check_cover_is_stored() def test_filesystem_does_not_pick_up_folder(self): os.makedirs(os.path.join(self.album.path, b'mycover.jpg')) From 54a83fa94182485189b5beafcbcc53f09adaf491 Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 20 Dec 2018 18:38:14 +0100 Subject: [PATCH 005/149] Fetchart / ignore settings tests --- test/test_fetchart.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/test_fetchart.py b/test/test_fetchart.py index a3c783766..e981467ff 100644 --- a/test/test_fetchart.py +++ b/test/test_fetchart.py @@ -54,6 +54,43 @@ class FetchartCliTest(unittest.TestCase, TestHelper): self.album.load() self.assertEqual(self.album['artpath'], None) + def test_filesystem_does_not_pick_up_ignored_file(self): + self.touch(b'co_ver.jpg', dir=self.album.path, content='IMAGE') + self.config['ignore'] = ['*_*'] + self.run_command('fetchart') + self.album.load() + self.assertEqual(self.album['artpath'], None) + + def test_filesystem_picks_up_non_ignored_file(self): + self.touch(b'cover.jpg', dir=self.album.path, content='IMAGE') + self.config['ignore'] = ['*_*'] + self.run_command('fetchart') + self.album.load() + self.check_cover_is_stored() + + def test_filesystem_does_not_pick_up_hidden_file(self): + self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') + self.config['ignore'] = [] # By default, ignore includes '.*'. + self.config['ignore_hidden'] = True + self.run_command('fetchart') + self.album.load() + self.assertEqual(self.album['artpath'], None) + + def test_filesystem_picks_up_non_hidden_file(self): + self.touch(b'cover.jpg', dir=self.album.path, content='IMAGE') + self.config['ignore_hidden'] = True + self.run_command('fetchart') + self.album.load() + self.check_cover_is_stored() + + def test_filesystem_picks_up_hidden_file(self): + self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') + self.config['ignore'] = [] # By default, ignore includes '.*'. + self.config['ignore_hidden'] = False + self.run_command('fetchart') + self.album.load() + self.check_cover_is_stored() + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From f955f72e2cb86a0ed20f0fc5e39fbf3737da0dc0 Mon Sep 17 00:00:00 2001 From: Reg Date: Fri, 21 Dec 2018 11:02:54 +0100 Subject: [PATCH 006/149] test_fetchart: Fix for hidden files on Windows --- test/test_fetchart.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/test_fetchart.py b/test/test_fetchart.py index e981467ff..8288e8f71 100644 --- a/test/test_fetchart.py +++ b/test/test_fetchart.py @@ -15,7 +15,9 @@ from __future__ import division, absolute_import, print_function +import ctypes import os +import sys import unittest from test.helper import TestHelper from beets import util @@ -40,6 +42,13 @@ class FetchartCliTest(unittest.TestCase, TestHelper): with open(util.syspath(self.cover_path), 'r') as f: self.assertEqual(f.read(), 'IMAGE') + def hide_file_windows(self): + hidden_mask = 2 + success = ctypes.windll.kernel32.SetFileAttributesW(self.cover_path, + hidden_mask) + if not success: + self.skipTest("unable to set file attributes") + def test_set_art_from_folder(self): self.touch(b'c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE') @@ -70,6 +79,8 @@ class FetchartCliTest(unittest.TestCase, TestHelper): def test_filesystem_does_not_pick_up_hidden_file(self): self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') + if sys.platform == 'win32': + self.hide_file_windows() self.config['ignore'] = [] # By default, ignore includes '.*'. self.config['ignore_hidden'] = True self.run_command('fetchart') @@ -85,6 +96,8 @@ class FetchartCliTest(unittest.TestCase, TestHelper): def test_filesystem_picks_up_hidden_file(self): self.touch(b'.cover.jpg', dir=self.album.path, content='IMAGE') + if sys.platform == 'win32': + self.hide_file_windows() self.config['ignore'] = [] # By default, ignore includes '.*'. self.config['ignore_hidden'] = False self.run_command('fetchart') From afe271dda2c7b4489569069925b524e4535633ac Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 21 Dec 2018 11:29:18 -0500 Subject: [PATCH 007/149] Changelog/thanks for #3097 --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e9b751fe..cfe7924db 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,10 @@ New features: :bug:`2497` * Modify selection can now be applied early without selecting every item. :bug:`3083` +* :doc:`/plugins/chroma`: Fingerprint values are now properly stored as + strings, which prevents strange repeated output when running ``beet write``. + Thanks to :user:`Holzhaus`. + :bug:`3097` :bug:`2942` Changes: From 5d1c97824c31493935a6c10eb2fadf83ff2d3b66 Mon Sep 17 00:00:00 2001 From: Reg Date: Sun, 23 Dec 2018 17:48:44 +0100 Subject: [PATCH 008/149] Unit tests for fetchart plugin iTunes source --- test/test_art.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/test/test_art.py b/test/test_art.py index a95cd4c95..7848ae80f 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -274,6 +274,88 @@ class AAOTest(UseThePlugin): next(self.source.get(album, self.settings, [])) +class ITunesStoreTest(UseThePlugin): + def setUp(self): + super(ITunesStoreTest, self).setUp() + self.source = fetchart.ITunesStore(logger, self.plugin.config) + self.settings = Settings() + self.album = _common.Bag(albumartist="some artist", album="some album") + + @responses.activate + def run(self, *args, **kwargs): + super(ITunesStoreTest, self).run(*args, **kwargs) + + def mock_response(self, url, json): + responses.add(responses.GET, url, body=json, + content_type='application/json') + + def test_itunesstore_finds_image(self): + json = """{ + "results": + [ + { + "artistName": "some artist", + "collectionName": "some album", + "artworkUrl100": "url_to_the_image" + } + ] + }""" + self.mock_response(fetchart.ITunesStore.API_URL, json) + candidate = next(self.source.get(self.album, self.settings, [])) + self.assertEqual(candidate.url, 'url_to_the_image') + + def test_itunesstore_no_result(self): + json = '{"results": []}' + self.mock_response(fetchart.ITunesStore.API_URL, json) + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + def test_itunesstore_bad_url(self): + self.mock_response(u'https://www.apple.com', "") + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + def test_itunesstore_returns_result_without_artist(self): + json = """{ + "results": + [ + { + "collectionName": "some album", + "artworkUrl100": "url_to_the_image" + } + ] + }""" + self.mock_response(fetchart.ITunesStore.API_URL, json) + candidate = next(self.source.get(self.album, self.settings, [])) + self.assertEqual(candidate.url, 'url_to_the_image') + + def test_itunesstore_returns_result_without_artwork(self): + json = """{ + "results": + [ + { + "artistName": "some artist", + "collectionName": "some album" + } + ] + }""" + self.mock_response(fetchart.ITunesStore.API_URL, json) + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + def test_itunesstore_returns_no_result_when_error_received(self): + json = '{"error": {"errors": [{"reason": "some reason"}]}}' + self.mock_response(fetchart.ITunesStore.API_URL, json) + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + def test_itunesstore_returns_no_result_with_malformed_response(self): + json = """bla blup""" + self.mock_response(fetchart.ITunesStore.API_URL, json) + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + class GoogleImageTest(UseThePlugin): def setUp(self): super(GoogleImageTest, self).setUp() From ac09c480c9bea7f8a76d77b627855e82c6b9370a Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 27 Dec 2018 09:39:18 +0100 Subject: [PATCH 009/149] test_art/iTunesStore: removed unnecessary statement and renamed test --- test/test_art.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 7848ae80f..3b524feda 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -310,8 +310,7 @@ class ITunesStoreTest(UseThePlugin): with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) - def test_itunesstore_bad_url(self): - self.mock_response(u'https://www.apple.com', "") + def test_itunesstore_requestexception(self): with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) From fa32485755e5cc1c9720a6fe465a438ce1d97d5d Mon Sep 17 00:00:00 2001 From: Reg Date: Thu, 10 Jan 2019 19:59:17 +0100 Subject: [PATCH 010/149] test_art/iTunesStore: requestexception test improvements --- test/test_art.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 3b524feda..012d474ee 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -25,6 +25,7 @@ import responses from mock import patch from test import _common +from test.helper import capture_log from beetsplug import fetchart from beets.autotag import AlbumInfo, AlbumMatch from beets import config @@ -311,8 +312,15 @@ class ITunesStoreTest(UseThePlugin): next(self.source.get(self.album, self.settings, [])) def test_itunesstore_requestexception(self): - with self.assertRaises(StopIteration): - next(self.source.get(self.album, self.settings, [])) + responses.add(responses.GET, fetchart.ITunesStore.API_URL, + json={'error': 'not found'}, status=404) + expected = u'iTunes search failed: 404 Client Error' + + with capture_log('beets.test_art') as logs: + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + + self.assertIn(expected, logs[1]) def test_itunesstore_returns_result_without_artist(self): json = """{ From 0df0dfe98660c7a73cba7085321cd728c79d0e43 Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Thu, 10 Jan 2019 21:01:03 -0500 Subject: [PATCH 011/149] Maintain python 2 compatibility Jellyfish is no longer python 2 compatible as of release 0.7.0. By pinning the previous release, beets is still able to be installed and run on python 2 systems without issue. --- docs/changelog.rst | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c1382b61c..d6b265fba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -67,6 +67,7 @@ Changes: Fixes: +* Pin jellyfish requirement to version 0.6.0 to maintain python 2 compatibility. * A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks contained in data files :bug:`3021` * Restore iTunes Store album art source, and remove the dependency on diff --git a/setup.py b/setup.py index 19c03041a..c674db5a0 100755 --- a/setup.py +++ b/setup.py @@ -92,7 +92,7 @@ setup( 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', - 'jellyfish', + 'jellyfish==0.6.0', ] + (['colorama'] if (sys.platform == 'win32') else []) + (['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else []), From 57c27039d61e8ec3db444bb1ced718804a23dd05 Mon Sep 17 00:00:00 2001 From: Reg Date: Sat, 12 Jan 2019 19:54:49 +0100 Subject: [PATCH 012/149] test_art/iTunesStore: renamed test for clarity --- test/test_art.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_art.py b/test/test_art.py index 012d474ee..08f47f903 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -322,7 +322,7 @@ class ITunesStoreTest(UseThePlugin): self.assertIn(expected, logs[1]) - def test_itunesstore_returns_result_without_artist(self): + def test_itunesstore_fallback_match(self): json = """{ "results": [ From 1d0af470a54bb7daabebcb51768014bda6069542 Mon Sep 17 00:00:00 2001 From: RollingStar Date: Sat, 12 Jan 2019 16:04:13 -0500 Subject: [PATCH 013/149] More details on self-hosting musicbrainz Document important facts for self-hosting musicbrainz with beets. I don't want the beets docs to be the go-to reference for hosting MusicBrainz, but the search index requirement is not explained well on the beets wiki. Full disclosure, I haven't finished building my index yet so I'm not sure if that is actually why I can't self-host right now. But I remember doing it before to fix beets integration with my Musicbrainz server. --- docs/reference/config.rst | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 0cbe73723..09af74b3e 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -654,8 +654,8 @@ Default: ``{}`` (empty). MusicBrainz Options ------------------- -If you run your own `MusicBrainz`_ server, you can instruct beets to use it -instead of the main server. Use the ``host`` and ``ratelimit`` options under a +You can instruct beets to use `your own`_ MusicBrainz database instead of +the `main server`_. Use the ``host`` and ``ratelimit`` options under a ``musicbrainz:`` header, like so:: musicbrainz: @@ -663,14 +663,18 @@ instead of the main server. Use the ``host`` and ``ratelimit`` options under a ratelimit: 100 The ``host`` key, of course, controls the Web server hostname (and port, -optionally) that will be contacted by beets (default: musicbrainz.org). The -``ratelimit`` option, an integer, controls the number of Web service requests +optionally) that will be contacted by beets (default: musicbrainz.org). +The ``host`` must have search indexes enabled -- see "Building search indexes" +in the server setup guide linked above. + +The ``ratelimit`` option, an integer, controls the number of Web service requests per second (default: 1). **Do not change the rate limit setting** if you're using the main MusicBrainz server---on this public server, you're `limited`_ to one request per second. +.. _your own: https://musicbrainz.org/doc/MusicBrainz_Server/Setup +.. _main server: https://musicbrainz.org/ .. _limited: http://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting -.. _MusicBrainz: http://musicbrainz.org/ .. _searchlimit: From d35a68d0d8cfa4b1481365f5bee8ae2bc0c96dbf Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 12 Jan 2019 14:20:16 -0800 Subject: [PATCH 014/149] Refine writing/links for #3116 --- docs/reference/config.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 09af74b3e..57cb6c295 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -654,7 +654,7 @@ Default: ``{}`` (empty). MusicBrainz Options ------------------- -You can instruct beets to use `your own`_ MusicBrainz database instead of +You can instruct beets to use `your own MusicBrainz database`_ instead of the `main server`_. Use the ``host`` and ``ratelimit`` options under a ``musicbrainz:`` header, like so:: @@ -664,17 +664,17 @@ the `main server`_. Use the ``host`` and ``ratelimit`` options under a The ``host`` key, of course, controls the Web server hostname (and port, optionally) that will be contacted by beets (default: musicbrainz.org). -The ``host`` must have search indexes enabled -- see "Building search indexes" -in the server setup guide linked above. +The server must have search indices enabled (see `Building search indexes`_). The ``ratelimit`` option, an integer, controls the number of Web service requests per second (default: 1). **Do not change the rate limit setting** if you're using the main MusicBrainz server---on this public server, you're `limited`_ to one request per second. -.. _your own: https://musicbrainz.org/doc/MusicBrainz_Server/Setup +.. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup .. _main server: https://musicbrainz.org/ .. _limited: http://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting +.. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes .. _searchlimit: From a4100a28a59502755fee9f7c6a9b04bf8aa38212 Mon Sep 17 00:00:00 2001 From: RollingStar Date: Wed, 16 Jan 2019 17:56:27 -0500 Subject: [PATCH 015/149] More verbose move message --- beets/ui/commands.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 1ed03bb9e..0182369b6 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1490,18 +1490,23 @@ def move_items(lib, dest, query, copy, album, pretend, confirm=False, """ items, albums = _do_query(lib, query, album, False) objs = albums if album else items + num_objs = len(objs) # Filter out files that don't need to be moved. isitemmoved = lambda item: item.path != item.destination(basedir=dest) isalbummoved = lambda album: any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] - + num_unmoved = num_objs - len(objs) + unmoved_msg = u'' + if num_unmoved > 0: + unmoved_msg = u' ({} already in place)'.format(num_unmoved) + copy = copy or export # Exporting always copies. action = u'Copying' if copy else u'Moving' act = u'copy' if copy else u'move' entity = u'album' if album else u'item' - log.info(u'{0} {1} {2}{3}.', action, len(objs), entity, - u's' if len(objs) != 1 else u'') + log.info(u'{0} {1} {2}{3}{4}.', action, len(objs), entity, + u's' if len(objs) != 1 else u'', unmoved_msg) if not objs: return From f5086d0bc68323bfe1bbc749fce5c2e950954a26 Mon Sep 17 00:00:00 2001 From: RollingStar Date: Fri, 18 Jan 2019 17:15:29 -0500 Subject: [PATCH 016/149] Changelog and linting --- beets/ui/commands.py | 3 ++- docs/changelog.rst | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 0182369b6..a38be7a15 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1497,10 +1497,11 @@ def move_items(lib, dest, query, copy, album, pretend, confirm=False, isalbummoved = lambda album: any(isitemmoved(i) for i in album.items()) objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] num_unmoved = num_objs - len(objs) + # Report unmoved files that match the query. unmoved_msg = u'' if num_unmoved > 0: unmoved_msg = u' ({} already in place)'.format(num_unmoved) - + copy = copy or export # Exporting always copies. action = u'Copying' if copy else u'Moving' act = u'copy' if copy else u'move' diff --git a/docs/changelog.rst b/docs/changelog.rst index c1382b61c..d2c487906 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,10 @@ New features: strings, which prevents strange repeated output when running ``beet write``. Thanks to :user:`Holzhaus`. :bug:`3097` :bug:`2942` +* The ``move`` command now lists the number of items already in-place. + Thanks to :user:`RollingStar`. + :bug:`3117 + Changes: From f4f67c36bb05851278c28c4bec51bb31e88a5ab4 Mon Sep 17 00:00:00 2001 From: RollingStar Date: Fri, 18 Jan 2019 17:17:41 -0500 Subject: [PATCH 017/149] Changelog and linting --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d2c487906..e2a94aefd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -54,7 +54,7 @@ New features: :bug:`3097` :bug:`2942` * The ``move`` command now lists the number of items already in-place. Thanks to :user:`RollingStar`. - :bug:`3117 + :bug:`3117` Changes: From 088b5b173d63619412ae18a7a90706745f7b4d66 Mon Sep 17 00:00:00 2001 From: Reg Date: Sat, 19 Jan 2019 14:58:41 +0100 Subject: [PATCH 018/149] test_art/iTunesStore: Check match level is as expected --- test/test_art.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_art.py b/test/test_art.py index 08f47f903..67d9439af 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -304,6 +304,7 @@ class ITunesStoreTest(UseThePlugin): self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') + self.assertEqual(candidate.match, fetchart.Candidate.MATCH_EXACT) def test_itunesstore_no_result(self): json = '{"results": []}' @@ -335,6 +336,7 @@ class ITunesStoreTest(UseThePlugin): self.mock_response(fetchart.ITunesStore.API_URL, json) candidate = next(self.source.get(self.album, self.settings, [])) self.assertEqual(candidate.url, 'url_to_the_image') + self.assertEqual(candidate.match, fetchart.Candidate.MATCH_FALLBACK) def test_itunesstore_returns_result_without_artwork(self): json = """{ From 204a1453c483acfea2caf9f54f4a4a89a9a74530 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sat, 19 Jan 2019 18:06:17 -0800 Subject: [PATCH 019/149] 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 020/149] `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 021/149] 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 022/149] 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 023/149] 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 024/149] 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 025/149] 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 026/149] 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 027/149] 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 028/149] 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 029/149] 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 030/149] 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 031/149] 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 032/149] 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 033/149] 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 034/149] 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 035/149] 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 036/149] 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 037/149] 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 038/149] 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 039/149] 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 040/149] 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 041/149] 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 042/149] 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 043/149] 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 ce35c36762569aa02dda9067eff6af1d2f26a3dc Mon Sep 17 00:00:00 2001 From: Reg Date: Sun, 20 Jan 2019 23:09:18 +0100 Subject: [PATCH 044/149] test_art/iTunesStore: Verify logs. --- test/test_art.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 67d9439af..08b5d774e 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -309,8 +309,12 @@ class ITunesStoreTest(UseThePlugin): def test_itunesstore_no_result(self): json = '{"results": []}' self.mock_response(fetchart.ITunesStore.API_URL, json) - with self.assertRaises(StopIteration): - next(self.source.get(self.album, self.settings, [])) + expected = u"iTunes search for 'some artist some album' got no results" + + with capture_log('beets.test_art') as logs: + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + self.assertIn(expected, logs[1]) def test_itunesstore_requestexception(self): responses.add(responses.GET, fetchart.ITunesStore.API_URL, @@ -320,7 +324,6 @@ class ITunesStoreTest(UseThePlugin): with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): next(self.source.get(self.album, self.settings, [])) - self.assertIn(expected, logs[1]) def test_itunesstore_fallback_match(self): @@ -349,20 +352,32 @@ class ITunesStoreTest(UseThePlugin): ] }""" self.mock_response(fetchart.ITunesStore.API_URL, json) - with self.assertRaises(StopIteration): - next(self.source.get(self.album, self.settings, [])) + expected = u'Malformed itunes candidate' + + with capture_log('beets.test_art') as logs: + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + self.assertIn(expected, logs[1]) def test_itunesstore_returns_no_result_when_error_received(self): json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.ITunesStore.API_URL, json) - with self.assertRaises(StopIteration): - next(self.source.get(self.album, self.settings, [])) + expected = u"'results' not found in json. Fields are ['error']" + + with capture_log('beets.test_art') as logs: + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + self.assertIn(expected, logs[1]) def test_itunesstore_returns_no_result_with_malformed_response(self): json = """bla blup""" self.mock_response(fetchart.ITunesStore.API_URL, json) - with self.assertRaises(StopIteration): - next(self.source.get(self.album, self.settings, [])) + expected = u"Could not decode json response: Expecting value" + + with capture_log('beets.test_art') as logs: + with self.assertRaises(StopIteration): + next(self.source.get(self.album, self.settings, [])) + self.assertIn(expected, logs[1]) class GoogleImageTest(UseThePlugin): From f61aacf04bfa492df369b0fde6cb94aeea2af0ff Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Sun, 20 Jan 2019 14:40:32 -0800 Subject: [PATCH 045/149] 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 046/149] 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 047/149] 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 048/149] 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 049/149] 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 050/149] 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 051/149] 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 052/149] 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 053/149] 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( From 1be3c954f3df3387e87c5212dcd9bbce8b88f8ae Mon Sep 17 00:00:00 2001 From: Reg Date: Mon, 21 Jan 2019 18:26:58 +0100 Subject: [PATCH 054/149] test_art/iTunesStore: Python2 string fix. --- test/test_art.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_art.py b/test/test_art.py index 08b5d774e..897cf5812 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -309,7 +309,7 @@ class ITunesStoreTest(UseThePlugin): def test_itunesstore_no_result(self): json = '{"results": []}' self.mock_response(fetchart.ITunesStore.API_URL, json) - expected = u"iTunes search for 'some artist some album' got no results" + expected = u"got no results" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): @@ -362,7 +362,7 @@ class ITunesStoreTest(UseThePlugin): def test_itunesstore_returns_no_result_when_error_received(self): json = '{"error": {"errors": [{"reason": "some reason"}]}}' self.mock_response(fetchart.ITunesStore.API_URL, json) - expected = u"'results' not found in json. Fields are ['error']" + expected = u"not found in json. Fields are" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): From 10083c61b1764da57940b7f40a081c49b212c945 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 21 Jan 2019 15:12:33 -0500 Subject: [PATCH 055/149] Changelog for #3123 --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index e2a94aefd..b896604c2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -55,6 +55,14 @@ New features: * The ``move`` command now lists the number of items already in-place. Thanks to :user:`RollingStar`. :bug:`3117` +* :doc:`/plugins/spotify`: The plugin now uses OAuth for authentication to the + Spotify API. + Thanks to :user:`rhlahuja`. + :bug:`2694` :bug:`3123` +* :doc:`/plugins/spotify`: The plugin now works as an import metadata + provider: you can match tracks and albums using the Spotify database. + Thanks to :user:`rhlahuja`. + :bug:`3123` Changes: From 272bf5940b71c47eb2fba16a2e24a591a4d99f9f Mon Sep 17 00:00:00 2001 From: Reg Date: Mon, 21 Jan 2019 22:49:19 +0100 Subject: [PATCH 056/149] test_art/iTunesStore: log check fix. --- test/test_art.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_art.py b/test/test_art.py index 897cf5812..857f5d3c6 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -372,7 +372,7 @@ class ITunesStoreTest(UseThePlugin): def test_itunesstore_returns_no_result_with_malformed_response(self): json = """bla blup""" self.mock_response(fetchart.ITunesStore.API_URL, json) - expected = u"Could not decode json response: Expecting value" + expected = u"Could not decode json response:" with capture_log('beets.test_art') as logs: with self.assertRaises(StopIteration): From 5472a4999150306de0494d15a31353ac81d985df Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 21:24:41 -0800 Subject: [PATCH 057/149] Add `candidates` and `item_candidates`, modularize Search API queries --- beetsplug/spotify.py | 175 +++++++++++++++++++++++++++++-------------- test/test_spotify.py | 14 ++-- 2 files changed, 127 insertions(+), 62 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 246e65a6f..9a08fa709 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -85,7 +85,7 @@ class SpotifyPlugin(BeetsPlugin): except requests.exceptions.HTTPError as e: raise ui.UserError( u'Spotify authorization failed: {}\n{}'.format( - e, response.content + e, response.text ) ) self.access_token = response.json()['access_token'] @@ -106,8 +106,8 @@ class SpotifyPlugin(BeetsPlugin): :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 + :return: JSON data for the class:`Response ` object + :rtype: dict """ response = request_type( url, @@ -123,7 +123,7 @@ class SpotifyPlugin(BeetsPlugin): self._handle_response(request_type, url, params=params) else: raise ui.UserError(u'Spotify API error:\n{}', response.text) - return response + return response.json() def _get_spotify_id(self, url_type, id_): """Parse a Spotify ID from its URL if necessary. @@ -155,10 +155,9 @@ class SpotifyPlugin(BeetsPlugin): if spotify_id is None: return None - response = self._handle_response( + response_data = self._handle_response( requests.get, self.album_url + spotify_id ) - response_data = response.json() artist, artist_id = self._get_artist(response_data['artists']) date_parts = [ @@ -244,18 +243,16 @@ class SpotifyPlugin(BeetsPlugin): if spotify_id is None: return None - response_track = self._handle_response( + response_data_track = self._handle_response( requests.get, self.track_url + spotify_id ) - 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 # the entire release - response_album = self._handle_response( + response_data_album = self._handle_response( requests.get, self.album_url + response_data_track['album']['id'] ) - response_data_album = response_album.json() medium_total = 0 for i, track_data in enumerate(response_data_album['tracks']['items']): if track_data['disc_number'] == track.medium: @@ -308,12 +305,92 @@ class SpotifyPlugin(BeetsPlugin): dist.add('source', self.config['source_weight'].as_number()) return dist + def candidates(self, items, artist, album, va_likely): + """Returns a list of AlbumInfo objects for Spotify Search results + matching an ``album`` and ``artist`` (if not various). + """ + query_filters = {'album': album} + if not va_likely: + query_filters['artist'] = artist + response_data = self._search_spotify( + query_type='album', filters=query_filters + ) + return [ + self.album_for_id(album_id=album_data['id']) + for album_data in response_data['albums']['items'] + ] + + def item_candidates(self, item, artist, title): + """Returns a list of TrackInfo objects for Spotify Search results + matching ``title`` and ``artist``. + """ + response_data = self._search_spotify( + query_type='track', keywords=title, filters={'artist': artist} + ) + return [ + self._get_track(track_data) + for track_data in response_data['tracks']['items'] + ] + + @staticmethod + def _construct_search_query(filters=None, keywords=''): + """Construct a query string with the specified filters and keywords to + be provided to the Spotify Search API + (https://developer.spotify.com/documentation/web-api/reference/search/search/). + + :param filters: (Optional) Field filters to apply. + :type filters: dict + :param keywords: (Optional) Query keywords to use. + :type keywords: str + :return: Search query string to be provided to the Search API + (https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines) + :rtype: str + """ + query_string = keywords + if filters is not None: + query_string += ' ' + ' '.join( + ':'.join((k, v)) for k, v in filters.items() + ) + return query_string + + def _search_spotify(self, query_type, filters=None, keywords=''): + """Query the Spotify Search API for the specified ``keywords``, applying + the provided filters. + + :param query_type: A comma-separated list of item types to search + across. Valid types are: 'album', 'artist', 'playlist', and + 'track'. Search results include hits from all the specified item + types. + :type query_type: str + :param filters: (Optional) Field filters to apply. + :type filters: dict + :param keywords: (Optional) Query keywords to use. + :return: JSON data for the class:`Response ` object + :rtype: dict + """ + query = self._construct_search_query( + keywords=keywords, filters=filters + ) + if not query: + return None + self._log.debug(u'Searching Spotify for "{}"'.format(query)) + response_data = self._handle_response( + requests.get, + self.search_url, + params={'q': query, 'type': query_type}, + ) + num_results = 0 + for result_type_data in response_data.values(): + num_results += len(result_type_data['items']) + self._log.debug(u'Found {} results from Spotify'.format(num_results)) + return response_data if num_results > 0 else None + def commands(self): def queries(lib, opts, args): - success = self.parse_opts(opts) + success = self._parse_opts(opts) if success: - results = self.query_spotify(lib, ui.decargs(args)) - self.output_results(results) + results = self._query_spotify(lib, ui.decargs(args)) + self._output_results(results) spotify_cmd = ui.Subcommand( 'spotify', help=u'build a Spotify playlist' @@ -335,7 +412,7 @@ class SpotifyPlugin(BeetsPlugin): spotify_cmd.func = queries return [spotify_cmd] - def parse_opts(self, opts): + def _parse_opts(self, opts): if opts.mode: self.config['mode'].set(opts.mode) @@ -351,19 +428,19 @@ class SpotifyPlugin(BeetsPlugin): self.opts = opts return True - def query_spotify(self, lib, query): + def _query_spotify(self, lib, keywords): results = [] failures = [] - items = lib.items(query) + items = lib.items(keywords) if not items: self._log.debug( - u'Your beets query returned no items, skipping Spotify' + u'Your beets query returned no items, skipping Spotify.' ) return - self._log.info(u'Processing {0} tracks...', len(items)) + self._log.info(u'Processing {} tracks...', len(items)) for item in items: # Apply regex transformations if provided @@ -383,81 +460,69 @@ class SpotifyPlugin(BeetsPlugin): # 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()] - query_keywords = '{} album:{} artist:{}'.format( - query, album, artist - ) + keywords = item[self.config['track_field'].get()] # Query the Web API for each track, look for the items' JSON data - try: - response = self._handle_response( - requests.get, - self.search_url, - params={'q': query_keywords, 'type': 'track'}, + query_filters = {'artist': artist, 'album': album} + response_data = self._search_spotify( + query_type='track', keywords=keywords, filters=query_filters + ) + if response_data is None: + query = self._construct_search_query( + keywords=keywords, filters=query_filters ) - except ui.UserError: - failures.append(query_keywords) + failures.append(query) continue - - response_data = response.json()['tracks']['items'] + response_data_tracks = response_data['tracks']['items'] # Apply market filter if requested region_filter = self.config['region_filter'].get() if region_filter: - response_data = [ + response_data_tracks = [ x - for x in response_data + for x in response_data_tracks if region_filter in x['available_markets'] ] - # Simplest, take the first result - chosen_result = None if ( - len(response_data) == 1 + len(response_data_tracks) == 1 or self.config['tiebreak'].get() == 'first' ): self._log.debug( - u'Spotify track(s) found, count: {0}', len(response_data) + u'Spotify track(s) found, count: {}', + len(response_data_tracks), ) - chosen_result = response_data[0] - elif len(response_data) > 1: + chosen_result = response_data_tracks[0] + else: # Use the popularity filter self._log.debug( - u'Most popular track chosen, count: {0}', - len(response_data), + u'Most popular track chosen, count: {}', + len(response_data_tracks), ) chosen_result = max( - response_data, key=lambda x: x['popularity'] + response_data_tracks, key=lambda x: x['popularity'] ) - - if chosen_result: - results.append(chosen_result) - else: - self._log.debug( - u'No Spotify track found for the following query: {}', - query_keywords, - ) - failures.append(query_keywords) + results.append(chosen_result) 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 + u'{} 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'track: {}', track) self._log.info(u'') else: self._log.warning( - u'{0} track(s) did not match a Spotify ID;\n' + u'{} track(s) did not match a Spotify ID;\n' u'use --show-failures to display', failure_count, ) return results - def output_results(self, results): + def _output_results(self, results): if results: ids = [x['id'] for x in results] if self.config['mode'].get() == "open": diff --git a/test/test_spotify.py b/test/test_spotify.py index 221c21e74..99e09e208 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -47,19 +47,19 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): ) self.spotify = spotify.SpotifyPlugin() opts = ArgumentsMock("list", False) - self.spotify.parse_opts(opts) + 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)) + self.assertEqual(False, self.spotify._parse_opts(opts)) opts = ArgumentsMock("list", False) - self.assertEqual(True, self.spotify.parse_opts(opts)) + 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): @@ -84,7 +84,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): length=10, ) item.add(self.lib) - self.assertEqual([], self.spotify.query_spotify(self.lib, u"")) + self.assertEqual([], self.spotify._query_spotify(self.lib, u"")) params = _params(responses.calls[0].request.url) self.assertEqual( @@ -116,10 +116,10 @@ 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) + self.spotify._output_results(results) params = _params(responses.calls[0].request.url) self.assertEqual( From 265fcc7cea9f7754e25b8849738f165e15861334 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 21:45:50 -0800 Subject: [PATCH 058/149] utilize `track_for_id` in `item_candidates` --- beetsplug/spotify.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 9a08fa709..dd80970a2 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -230,12 +230,16 @@ class SpotifyPlugin(BeetsPlugin): data_url=track_data['external_urls']['spotify'], ) - def track_for_id(self, track_id): + def track_for_id(self, track_id=None, track_data=None): """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 + :param track_id: (Optional) Spotify ID or URL for the track. Either + ``track_id`` or ``track_data`` must be provided. :type track_id: str + :param track_data: (Optional) Simplified track object dict. May be + provided instead of ``track_id`` to avoid unnecessary API calls. + :type track_data: dict :return: TrackInfo object for track :rtype: beets.autotag.hooks.TrackInfo """ @@ -243,15 +247,16 @@ class SpotifyPlugin(BeetsPlugin): if spotify_id is None: return None - response_data_track = self._handle_response( - requests.get, self.track_url + spotify_id - ) - track = self._get_track(response_data_track) + if track_data is None: + track_data = self._handle_response( + requests.get, self.track_url + spotify_id + ) + track = self._get_track(track_data) # get album's tracks to set the track's index/position on # the entire release response_data_album = self._handle_response( - requests.get, self.album_url + response_data_track['album']['id'] + requests.get, self.album_url + track_data['album']['id'] ) medium_total = 0 for i, track_data in enumerate(response_data_album['tracks']['items']): @@ -328,7 +333,7 @@ class SpotifyPlugin(BeetsPlugin): query_type='track', keywords=title, filters={'artist': artist} ) return [ - self._get_track(track_data) + self.track_for_id(track_data=track_data) for track_data in response_data['tracks']['items'] ] From aa18f9116dbddc0d889391401da9e988118e68de Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:01:30 -0800 Subject: [PATCH 059/149] Refine doc wording --- beetsplug/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index dd80970a2..cbb43ffef 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -311,7 +311,7 @@ class SpotifyPlugin(BeetsPlugin): return dist def candidates(self, items, artist, album, va_likely): - """Returns a list of AlbumInfo objects for Spotify Search results + """Returns a list of AlbumInfo objects for Spotify Search API results matching an ``album`` and ``artist`` (if not various). """ query_filters = {'album': album} @@ -326,7 +326,7 @@ class SpotifyPlugin(BeetsPlugin): ] def item_candidates(self, item, artist, title): - """Returns a list of TrackInfo objects for Spotify Search results + """Returns a list of TrackInfo objects for Spotify Search API results matching ``title`` and ``artist``. """ response_data = self._search_spotify( From 42e852cc7ed9db3a644b162094240b5e3f7bc55b Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:12:56 -0800 Subject: [PATCH 060/149] Clarify `_search_spotify` return type --- beetsplug/spotify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index cbb43ffef..152bea46d 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -370,8 +370,9 @@ class SpotifyPlugin(BeetsPlugin): :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. - :return: JSON data for the class:`Response ` object - :rtype: dict + :return: JSON data for the class:`Response ` object or None + if no search results are returned + :rtype: dict or None """ query = self._construct_search_query( keywords=keywords, filters=filters From 48401c60dc9464bddc8e15f8e379480ba8b3e90f Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:27:31 -0800 Subject: [PATCH 061/149] Switch query filter ordering for tests --- beetsplug/spotify.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 152bea46d..ee73640ae 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -320,6 +320,8 @@ class SpotifyPlugin(BeetsPlugin): response_data = self._search_spotify( query_type='album', filters=query_filters ) + if response_data is None: + return [] return [ self.album_for_id(album_id=album_data['id']) for album_data in response_data['albums']['items'] @@ -332,6 +334,8 @@ class SpotifyPlugin(BeetsPlugin): response_data = self._search_spotify( query_type='track', keywords=title, filters={'artist': artist} ) + if response_data is None: + return [] return [ self.track_for_id(track_data=track_data) for track_data in response_data['tracks']['items'] @@ -469,7 +473,7 @@ class SpotifyPlugin(BeetsPlugin): keywords = item[self.config['track_field'].get()] # Query the Web API for each track, look for the items' JSON data - query_filters = {'artist': artist, 'album': album} + query_filters = {'album': album, 'artist': artist} response_data = self._search_spotify( query_type='track', keywords=keywords, filters=query_filters ) From f63beca39ab3c0f476d3965e731985c3b90da006 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:35:12 -0800 Subject: [PATCH 062/149] Switch filter ordering in test --- beetsplug/spotify.py | 6 +++--- test/test_spotify.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index ee73640ae..a4179ef1c 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -255,11 +255,11 @@ class SpotifyPlugin(BeetsPlugin): # get album's tracks to set the track's index/position on # the entire release - response_data_album = self._handle_response( + album_data = self._handle_response( requests.get, self.album_url + track_data['album']['id'] ) medium_total = 0 - for i, track_data in enumerate(response_data_album['tracks']['items']): + for i, track_data in enumerate(album_data['tracks']['items']): if track_data['disc_number'] == track.medium: medium_total += 1 if track_data['id'] == spotify_id: @@ -473,7 +473,7 @@ class SpotifyPlugin(BeetsPlugin): keywords = item[self.config['track_field'].get()] # Query the Web API for each track, look for the items' JSON data - query_filters = {'album': album, 'artist': artist} + query_filters = {'artist': artist, 'album': album} response_data = self._search_spotify( query_type='track', keywords=keywords, filters=query_filters ) diff --git a/test/test_spotify.py b/test/test_spotify.py index 99e09e208..39eb38113 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -124,7 +124,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): params = _params(responses.calls[0].request.url) self.assertEqual( params['q'], - [u'Happy album:Despicable Me 2 artist:Pharrell Williams'], + [u'Happy artist:Pharrell Williams album:Despicable Me 2'], ) self.assertEqual(params['type'], [u'track']) From 237792a4fb9a4a80bfde2c3fc0aa9b814d06450b Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:40:15 -0800 Subject: [PATCH 063/149] Fix other `test_track_request` case --- test/test_spotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_spotify.py b/test/test_spotify.py index 39eb38113..25a58911d 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -89,7 +89,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): params = _params(responses.calls[0].request.url) self.assertEqual( params['q'], - [u'duifhjslkef album:lkajsdflakjsd artist:ujydfsuihse'], + [u'duifhjslkef artist:ujydfsuihse album:lkajsdflakjsd'], ) self.assertEqual(params['type'], [u'track']) From 2cda2b5b3a559ef2061f6bacc5ea0ef919cc53ef Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 22:53:23 -0800 Subject: [PATCH 064/149] Remove hardcoded ordering of filters in tests --- test/test_spotify.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_spotify.py b/test/test_spotify.py index 25a58911d..d6bec780d 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -122,10 +122,10 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.spotify._output_results(results) params = _params(responses.calls[0].request.url) - self.assertEqual( - params['q'], - [u'Happy artist:Pharrell Williams album:Despicable Me 2'], - ) + query = params['q'] + self.assertIn(u'Happy', query) + self.assertIn(u'artist:Pharrell Williams', query) + self.assertIn(u'album:Despicable Me 2', query) self.assertEqual(params['type'], [u'track']) From 0527edbd48a591597fe274d4073e154897d768a1 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 23:05:47 -0800 Subject: [PATCH 065/149] Fix test index, add docstrings --- beetsplug/spotify.py | 21 +++++++++++++++++++++ test/test_spotify.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index a4179ef1c..3ae22cacb 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -313,6 +313,17 @@ class SpotifyPlugin(BeetsPlugin): def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for Spotify Search API results matching an ``album`` and ``artist`` (if not various). + + :param items: List of tracks on the candidate album + :type items: list[beets.library.Item] + :param artist: The of the candidate album + :type artist: str + :param album: The name of the candidate album + :type album: str + :param va_likely: True if the candidate album likely has Various Artists + :type va_likely: bool + :return: Candidate AlbumInfo objects + :rtype: list[beets.autotag.hooks.AlbumInfo] """ query_filters = {'album': album} if not va_likely: @@ -330,6 +341,15 @@ class SpotifyPlugin(BeetsPlugin): def item_candidates(self, item, artist, title): """Returns a list of TrackInfo objects for Spotify Search API results matching ``title`` and ``artist``. + + :param item: Candidate track + :type item: beets.library.Item + :param artist: The artist of the candidate track + :type artist: str + :param title: The title of the candidate track + :type title: str + :return: Candidate TrackInfo objects + :rtype: list[beets.autotag.hooks.TrackInfo] """ response_data = self._search_spotify( query_type='track', keywords=title, filters={'artist': artist} @@ -374,6 +394,7 @@ class SpotifyPlugin(BeetsPlugin): :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. + :type keywords: str :return: JSON data for the class:`Response ` object or None if no search results are returned :rtype: dict or None diff --git a/test/test_spotify.py b/test/test_spotify.py index d6bec780d..e27092011 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -122,7 +122,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.spotify._output_results(results) params = _params(responses.calls[0].request.url) - query = params['q'] + query = params['q'][0] self.assertIn(u'Happy', query) self.assertIn(u'artist:Pharrell Williams', query) self.assertIn(u'album:Despicable Me 2', query) From 77f9a930b7ab60370b470a65a666f2f7eeb4c592 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 23:15:08 -0800 Subject: [PATCH 066/149] Fix remaining test, use official doc wording --- beetsplug/spotify.py | 17 +++++++++-------- test/test_spotify.py | 8 ++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 3ae22cacb..4d3f9afe6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -313,14 +313,15 @@ class SpotifyPlugin(BeetsPlugin): def candidates(self, items, artist, album, va_likely): """Returns a list of AlbumInfo objects for Spotify Search API results matching an ``album`` and ``artist`` (if not various). - - :param items: List of tracks on the candidate album + + :param items: List of items comprised by an album to be matched :type items: list[beets.library.Item] - :param artist: The of the candidate album + :param artist: The artist of the album to be matched :type artist: str - :param album: The name of the candidate album + :param album: The name of the album to be matched :type album: str - :param va_likely: True if the candidate album likely has Various Artists + :param va_likely: True if the album to be matched likely has + Various Artists :type va_likely: bool :return: Candidate AlbumInfo objects :rtype: list[beets.autotag.hooks.AlbumInfo] @@ -342,11 +343,11 @@ class SpotifyPlugin(BeetsPlugin): """Returns a list of TrackInfo objects for Spotify Search API results matching ``title`` and ``artist``. - :param item: Candidate track + :param item: Singleton item to be matched :type item: beets.library.Item - :param artist: The artist of the candidate track + :param artist: The artist of the track to be matched :type artist: str - :param title: The title of the candidate track + :param title: The title of the track to be matched :type title: str :return: Candidate TrackInfo objects :rtype: list[beets.autotag.hooks.TrackInfo] diff --git a/test/test_spotify.py b/test/test_spotify.py index e27092011..06499e8d7 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -87,10 +87,10 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.assertEqual([], self.spotify._query_spotify(self.lib, u"")) params = _params(responses.calls[0].request.url) - self.assertEqual( - params['q'], - [u'duifhjslkef artist:ujydfsuihse album:lkajsdflakjsd'], - ) + query = params['q'][0] + self.assertIn(u'duifhjslkef', query) + self.assertIn(u'artist:ujydfsuihse', query) + self.assertIn(u'album:lkajsdflakjsd', query) self.assertEqual(params['type'], [u'track']) @responses.activate From 96fda0df0d40764bf5d052037552940eb50febd6 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 23:36:51 -0800 Subject: [PATCH 067/149] Docstring formatting --- beetsplug/spotify.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 4d3f9afe6..f0fbf9b52 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -106,7 +106,7 @@ class SpotifyPlugin(BeetsPlugin): :param params: (optional) list of tuples or bytes to send in the query string for the :class:`Request`. :type params: dict - :return: JSON data for the class:`Response ` object + :return: JSON data for the class:`Response ` object. :rtype: dict """ response = request_type( @@ -128,11 +128,11 @@ class SpotifyPlugin(BeetsPlugin): 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' + :param url_type: Type of Spotify URL, either 'album' or 'track'. :type url_type: str - :param id_: Spotify ID or URL + :param id_: Spotify ID or URL. :type id_: str - :return: Spotify ID + :return: Spotify ID. :rtype: str """ # Spotify IDs consist of 22 alphanumeric characters @@ -314,16 +314,16 @@ class SpotifyPlugin(BeetsPlugin): """Returns a list of AlbumInfo objects for Spotify Search API results matching an ``album`` and ``artist`` (if not various). - :param items: List of items comprised by an album to be matched + :param items: List of items comprised by an album to be matched. :type items: list[beets.library.Item] - :param artist: The artist of the album to be matched + :param artist: The artist of the album to be matched. :type artist: str - :param album: The name of the album to be matched + :param album: The name of the album to be matched. :type album: str :param va_likely: True if the album to be matched likely has - Various Artists + Various Artists. :type va_likely: bool - :return: Candidate AlbumInfo objects + :return: Candidate AlbumInfo objects. :rtype: list[beets.autotag.hooks.AlbumInfo] """ query_filters = {'album': album} @@ -343,13 +343,13 @@ class SpotifyPlugin(BeetsPlugin): """Returns a list of TrackInfo objects for Spotify Search API results matching ``title`` and ``artist``. - :param item: Singleton item to be matched + :param item: Singleton item to be matched. :type item: beets.library.Item - :param artist: The artist of the track to be matched + :param artist: The artist of the track to be matched. :type artist: str - :param title: The title of the track to be matched + :param title: The title of the track to be matched. :type title: str - :return: Candidate TrackInfo objects + :return: Candidate TrackInfo objects. :rtype: list[beets.autotag.hooks.TrackInfo] """ response_data = self._search_spotify( @@ -366,14 +366,13 @@ class SpotifyPlugin(BeetsPlugin): def _construct_search_query(filters=None, keywords=''): """Construct a query string with the specified filters and keywords to be provided to the Spotify Search API - (https://developer.spotify.com/documentation/web-api/reference/search/search/). + (https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines). :param filters: (Optional) Field filters to apply. :type filters: dict :param keywords: (Optional) Query keywords to use. :type keywords: str - :return: Search query string to be provided to the Search API - (https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines) + :return: Query string to be provided to the Search API. :rtype: str """ query_string = keywords @@ -385,7 +384,7 @@ class SpotifyPlugin(BeetsPlugin): def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying - the provided filters. + the provided ``filters``. :param query_type: A comma-separated list of item types to search across. Valid types are: 'album', 'artist', 'playlist', and @@ -397,7 +396,7 @@ class SpotifyPlugin(BeetsPlugin): :param keywords: (Optional) Query keywords to use. :type keywords: str :return: JSON data for the class:`Response ` object or None - if no search results are returned + if no search results are returned. :rtype: dict or None """ query = self._construct_search_query( From 09fc53eaea7cce41a4c65e5f249bfac37b6f47bc Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Mon, 21 Jan 2019 23:53:19 -0800 Subject: [PATCH 068/149] Only parse Spotify ID when necessary --- beetsplug/spotify.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index f0fbf9b52..31d28c842 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -243,11 +243,10 @@ 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: - return None - if track_data is None: + spotify_id = self._get_spotify_id('track', track_id) + if spotify_id is None: + return None track_data = self._handle_response( requests.get, self.track_url + spotify_id ) From 3a67eae46d1a1726089c03e9e9b90f8c6b74d34f Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 10:41:18 -0800 Subject: [PATCH 069/149] Use track attrs directly, better naming/docstrings --- beetsplug/spotify.py | 52 +++++++++++++++++++++++++++++--------------- test/test_spotify.py | 8 +++---- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 31d28c842..cc21f8f05 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -261,7 +261,7 @@ class SpotifyPlugin(BeetsPlugin): for i, track_data in enumerate(album_data['tracks']['items']): if track_data['disc_number'] == track.medium: medium_total += 1 - if track_data['id'] == spotify_id: + if track_data['id'] == track.track_id: track.index = i + 1 track.medium_total = medium_total return track @@ -419,8 +419,8 @@ class SpotifyPlugin(BeetsPlugin): def queries(lib, opts, args): success = self._parse_opts(opts) if success: - results = self._query_spotify(lib, ui.decargs(args)) - self._output_results(results) + results = self._match_library_tracks(lib, ui.decargs(args)) + self._output_match_results(results) spotify_cmd = ui.Subcommand( 'spotify', help=u'build a Spotify playlist' @@ -458,11 +458,23 @@ class SpotifyPlugin(BeetsPlugin): self.opts = opts return True - def _query_spotify(self, lib, keywords): + def _match_library_tracks(self, library, keywords): + """ + Get a list of simplified track objects dicts for library tracks + matching the specified ``keywords``. + + :param library: beets library object to query. + :type library: beets.library.Library + :param keywords: Query to match library items against. + :type keywords: str + :return: List of simplified track object dicts for library items + matching the specified query. + :rtype: list[dict] + """ results = [] failures = [] - items = lib.items(keywords) + items = library.items(keywords) if not items: self._log.debug( @@ -509,9 +521,9 @@ class SpotifyPlugin(BeetsPlugin): region_filter = self.config['region_filter'].get() if region_filter: response_data_tracks = [ - x - for x in response_data_tracks - if region_filter in x['available_markets'] + track_data + for track_data in response_data_tracks + if region_filter in track_data['available_markets'] ] if ( @@ -552,16 +564,22 @@ class SpotifyPlugin(BeetsPlugin): return results - def _output_results(self, results): - if results: - ids = [x['id'] for x in results] - if self.config['mode'].get() == "open": - self._log.info(u'Attempting to open Spotify with playlist') - spotify_url = self.playlist_partial + ",".join(ids) - webbrowser.open(spotify_url) + def _output_match_results(self, results): + """ + Open a playlist or print Spotify URLs for the provided track object dicts. + :param results: List of simplified track object dicts + (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) + :type results: list[dict] + """ + if results: + spotify_ids = [track_data['id'] for track_data in results] + if self.config['mode'].get() == 'open': + self._log.info(u'Attempting to open Spotify with playlist') + spotify_url = self.playlist_partial + ",".join(spotify_ids) + webbrowser.open(spotify_url) else: - for item in ids: - print(self.open_track_url + item) + for spotify_id in spotify_ids: + print(self.open_track_url + spotify_id) 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 06499e8d7..5c19cb0eb 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -59,7 +59,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): 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._match_library_tracks(self.lib, u"1=2")) @responses.activate def test_missing_request(self): @@ -84,7 +84,7 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): length=10, ) item.add(self.lib) - self.assertEqual([], self.spotify._query_spotify(self.lib, u"")) + self.assertEqual([], self.spotify._match_library_tracks(self.lib, u"")) params = _params(responses.calls[0].request.url) query = params['q'][0] @@ -116,10 +116,10 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): length=10, ) item.add(self.lib) - results = self.spotify._query_spotify(self.lib, u"Happy") + results = self.spotify._match_library_tracks(self.lib, u"Happy") self.assertEqual(1, len(results)) self.assertEqual(u"6NPVjNh8Jhru9xOmyQigds", results[0]['id']) - self.spotify._output_results(results) + self.spotify._output_match_results(results) params = _params(responses.calls[0].request.url) query = params['q'][0] From 7b57b0b6087f52aa4d04f00af9eff2a05280d300 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 10:53:18 -0800 Subject: [PATCH 070/149] Appease Flake8 --- beetsplug/spotify.py | 5 +++-- test/test_spotify.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index cc21f8f05..c387125f6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -462,7 +462,7 @@ class SpotifyPlugin(BeetsPlugin): """ Get a list of simplified track objects dicts for library tracks matching the specified ``keywords``. - + :param library: beets library object to query. :type library: beets.library.Library :param keywords: Query to match library items against. @@ -566,7 +566,8 @@ class SpotifyPlugin(BeetsPlugin): def _output_match_results(self, results): """ - Open a playlist or print Spotify URLs for the provided track object dicts. + Open a playlist or print Spotify URLs for the provided track + object dicts. :param results: List of simplified track object dicts (https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified) diff --git a/test/test_spotify.py b/test/test_spotify.py index 5c19cb0eb..ea54a13db 100644 --- a/test/test_spotify.py +++ b/test/test_spotify.py @@ -59,7 +59,9 @@ class SpotifyPluginTest(_common.TestCase, TestHelper): self.assertEqual(True, self.spotify._parse_opts(opts)) def test_empty_query(self): - self.assertEqual(None, self.spotify._match_library_tracks(self.lib, u"1=2")) + self.assertEqual( + None, self.spotify._match_library_tracks(self.lib, u"1=2") + ) @responses.activate def test_missing_request(self): From f7d20090e67a8c0bd74eca62bdd53ca7559f273a Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 12:14:52 -0800 Subject: [PATCH 071/149] Fix `_handle_response` reauth bug and empty str query construction --- beetsplug/spotify.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index c387125f6..51ef325e0 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -120,7 +120,7 @@ class SpotifyPlugin(BeetsPlugin): 'Spotify access token has expired. Reauthenticating.' ) self._authenticate() - self._handle_response(request_type, url, params=params) + return self._handle_response(request_type, url, params=params) else: raise ui.UserError(u'Spotify API error:\n{}', response.text) return response.json() @@ -374,12 +374,11 @@ class SpotifyPlugin(BeetsPlugin): :return: Query string to be provided to the Search API. :rtype: str """ - query_string = keywords - if filters is not None: - query_string += ' ' + ' '.join( - ':'.join((k, v)) for k, v in filters.items() - ) - return query_string + query_components = [ + keywords, + ' '.join(':'.join((k, v)) for k, v in filters.items()), + ] + return ' '.join([s for s in query_components if s]) def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying From c6b8f6d1437ec840f762d179702afddde94b5938 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 18:09:10 -0800 Subject: [PATCH 072/149] Fix formatting/spelling --- beetsplug/spotify.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 51ef325e0..c841a3221 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -143,7 +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 + """Fetch an album by its Spotify ID or URL and return an AlbumInfo object or None if the album is not found. :param album_id: Spotify ID or URL for the album @@ -177,7 +177,7 @@ class SpotifyPlugin(BeetsPlugin): else: raise ui.UserError( u"Invalid `release_date_precision` returned " - u"from Spotify API: '{}'".format(release_date_precision) + u"by Spotify API: '{}'".format(release_date_precision) ) tracks = [] @@ -231,7 +231,7 @@ class SpotifyPlugin(BeetsPlugin): ) def track_for_id(self, track_id=None, track_data=None): - """Fetches a track by its Spotify ID or URL and returns a + """Fetch a track by its Spotify ID or URL and return a TrackInfo object or None if the track is not found. :param track_id: (Optional) Spotify ID or URL for the track. Either @@ -252,8 +252,9 @@ class SpotifyPlugin(BeetsPlugin): ) track = self._get_track(track_data) - # get album's tracks to set the track's index/position on - # the entire release + # Get album's tracks to set `track.index` (position on the entire + # release) and `track.medium_total` (total number of tracks on + # the track's disc). album_data = self._handle_response( requests.get, self.album_url + track_data['album']['id'] ) @@ -378,7 +379,7 @@ class SpotifyPlugin(BeetsPlugin): keywords, ' '.join(':'.join((k, v)) for k, v in filters.items()), ] - return ' '.join([s for s in query_components if s]) + return ' '.join([q for q in query_components if q]) def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying @@ -402,7 +403,9 @@ class SpotifyPlugin(BeetsPlugin): ) if not query: return None - self._log.debug(u'Searching Spotify for "{}"'.format(query)) + self._log.debug( + u'Searching Spotify for "{}"'.format(query.decode('utf8')) + ) response_data = self._handle_response( requests.get, self.search_url, @@ -458,8 +461,7 @@ class SpotifyPlugin(BeetsPlugin): return True def _match_library_tracks(self, library, keywords): - """ - Get a list of simplified track objects dicts for library tracks + """Get a list of simplified track object dicts for library tracks matching the specified ``keywords``. :param library: beets library object to query. @@ -564,8 +566,7 @@ class SpotifyPlugin(BeetsPlugin): return results def _output_match_results(self, results): - """ - Open a playlist or print Spotify URLs for the provided track + """Open a playlist or print Spotify URLs for the provided track object dicts. :param results: List of simplified track object dicts From b91406b252e6176d5e4430b4ae5ef187c7a9d1c4 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 18:59:15 -0800 Subject: [PATCH 073/149] Backwords-compatible str/unicode --- beetsplug/spotify.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index c841a3221..e297af940 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -9,6 +9,7 @@ import webbrowser import collections import requests +import six from beets import ui from beets.plugins import BeetsPlugin @@ -379,7 +380,10 @@ class SpotifyPlugin(BeetsPlugin): keywords, ' '.join(':'.join((k, v)) for k, v in filters.items()), ] - return ' '.join([q for q in query_components if q]) + query = ' '.join([q for q in query_components if q]) + if not isinstance(query, six.text_type): + query = query.decode('utf8') + return query def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying @@ -403,9 +407,7 @@ class SpotifyPlugin(BeetsPlugin): ) if not query: return None - self._log.debug( - u'Searching Spotify for "{}"'.format(query.decode('utf8')) - ) + self._log.debug(u"Searching Spotify for '{}'".format(query)) response_data = self._handle_response( requests.get, self.search_url, @@ -414,7 +416,9 @@ class SpotifyPlugin(BeetsPlugin): num_results = 0 for result_type_data in response_data.values(): num_results += len(result_type_data['items']) - self._log.debug(u'Found {} results from Spotify'.format(num_results)) + self._log.debug( + u"Found {} results from Spotify for '{}'", num_results, query + ) return response_data if num_results > 0 else None def commands(self): From 8c6cc6573c3ce775dc3717ca3c510aed908c929d Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 19:42:25 -0800 Subject: [PATCH 074/149] Unidecode query string --- beetsplug/spotify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index e297af940..032306f78 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -8,8 +8,9 @@ import base64 import webbrowser import collections -import requests import six +import unidecode +import requests from beets import ui from beets.plugins import BeetsPlugin @@ -383,7 +384,7 @@ class SpotifyPlugin(BeetsPlugin): query = ' '.join([q for q in query_components if q]) if not isinstance(query, six.text_type): query = query.decode('utf8') - return query + return unidecode.unidecode(query) def _search_spotify(self, query_type, filters=None, keywords=''): """Query the Spotify Search API for the specified ``keywords``, applying From 6a9b62a9c2d1919a036b3493b1db9c95128c6891 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Tue, 22 Jan 2019 20:54:46 -0800 Subject: [PATCH 075/149] Specify None rtype in doscstrings --- beetsplug/spotify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 032306f78..b94d0e8e6 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -151,7 +151,7 @@ class SpotifyPlugin(BeetsPlugin): :param album_id: Spotify ID or URL for the album :type album_id: str :return: AlbumInfo object for album - :rtype: beets.autotag.hooks.AlbumInfo + :rtype: beets.autotag.hooks.AlbumInfo or None """ spotify_id = self._get_spotify_id('album', album_id) if spotify_id is None: @@ -243,7 +243,7 @@ class SpotifyPlugin(BeetsPlugin): provided instead of ``track_id`` to avoid unnecessary API calls. :type track_data: dict :return: TrackInfo object for track - :rtype: beets.autotag.hooks.TrackInfo + :rtype: beets.autotag.hooks.TrackInfo or None """ if track_data is None: spotify_id = self._get_spotify_id('track', track_id) From 5fbb28637d844888fa1081dd61cd5d771d607939 Mon Sep 17 00:00:00 2001 From: Rahul Ahuja Date: Wed, 23 Jan 2019 19:54:14 -0800 Subject: [PATCH 076/149] Set Spotify AlbumInfo.mediums --- beets/autotag/hooks.py | 4 ++-- beetsplug/spotify.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index d15cba269..ec7047b7c 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -72,8 +72,8 @@ class AlbumInfo(object): - ``data_source``: The original data source (MusicBrainz, Discogs, etc.) - ``data_url``: The data source release URL. - The fields up through ``tracks`` are required. The others are - optional and may be None. + ``mediums`` along with the fields up through ``tracks`` are required. + The others are optional and may be None. """ def __init__(self, album, album_id, artist, artist_id, tracks, asin=None, albumtype=None, va=False, year=None, month=None, day=None, diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index b94d0e8e6..75f2c8523 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -205,6 +205,7 @@ class SpotifyPlugin(BeetsPlugin): month=month, day=day, label=response_data['label'], + mediums=max(medium_totals.keys()), data_source='Spotify', data_url=response_data['external_urls']['spotify'], ) @@ -286,8 +287,6 @@ class SpotifyPlugin(BeetsPlugin): 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) From b7f75878b0f5db0c89cb01bb30601ca7083c16a2 Mon Sep 17 00:00:00 2001 From: nichobi Date: Thu, 24 Jan 2019 16:07:44 +0100 Subject: [PATCH 077/149] docs: remove references to bs1770gain for #3127 --- docs/plugins/replaygain.rst | 39 +++---------------------------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 2b0ece053..08c6b4a99 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -10,9 +10,9 @@ playback levels. Installation ------------ -This plugin can use one of four backends to compute the ReplayGain values: -GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools and bs1770gain. mp3gain -can be easier to install but GStreamer, Audio Tools and bs1770gain support more audio +This plugin can use one of three backends to compute the ReplayGain values: +GStreamer, mp3gain (and its cousin, aacgain), Python Audio Tools. mp3gain +can be easier to install but GStreamer and Audio Tools support more audio formats. Once installed, this plugin analyzes all files during the import process. This @@ -75,25 +75,6 @@ On OS X, most of the dependencies can be installed with `Homebrew`_:: .. _Python Audio Tools: http://audiotools.sourceforge.net -bs1770gain -`````````` - -To use this backend, you will need to install the `bs1770gain`_ command-line -tool, version 0.4.6 or greater. Follow the instructions at the `bs1770gain`_ -Web site and ensure that the tool is on your ``$PATH``. - -.. _bs1770gain: http://bs1770gain.sourceforge.net/ - -Then, enable the plugin (see :ref:`using-plugins`) and specify the -backend in your configuration file:: - - replaygain: - backend: bs1770gain - -For Windows users: the tool currently has issues with long and non-ASCII path -names. You may want to use the :ref:`asciify-paths` configuration option until -this is resolved. - Configuration ------------- @@ -108,10 +89,6 @@ configuration file. The available options are: Default: ``no``. - **targetlevel**: A number of decibels for the target loudness level. Default: 89. -- **r128**: A space separated list of formats that will use ``R128_`` tags with - integer values instead of the common ``REPLAYGAIN_`` tags with floating point - values. Requires the "bs1770gain" backend. - Default: ``Opus``. These options only work with the "command" backend: @@ -123,16 +100,6 @@ These options only work with the "command" backend: would keep clipping from occurring. Default: ``yes``. -These options only works with the "bs1770gain" backend: - -- **method**: The loudness scanning standard: either `replaygain` for - ReplayGain 2.0, `ebu` for EBU R128, or `atsc` for ATSC A/85. This dictates - the reference level: -18, -23, or -24 LUFS respectively. Default: - `replaygain` -- **chunk_at**: Splits an album in groups of tracks of this amount. - Useful when running into memory problems when analysing albums with - an exceptionally large amount of tracks. Default:5000 - Manual Analysis --------------- From 1f356d66a694ba5fe68d7b38ab59fc16e6233d5a Mon Sep 17 00:00:00 2001 From: David Logie Date: Fri, 25 Jan 2019 14:06:30 +0000 Subject: [PATCH 078/149] First attempt at fixing #3132. Add a new `no_clobber` config option that contains a list of "miscellaneous" metadata fields not to be overwritten by empty values. I'm not entirely happy with the `no_clobber` name. Suggestions welcome. --- beets/autotag/__init__.py | 9 +++++++-- beets/config_default.yaml | 13 +++++++++++++ docs/changelog.rst | 3 +++ docs/reference/config.rst | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 90d294c61..6783929cd 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -154,9 +154,14 @@ def apply_metadata(album_info, mapping): 'albumdisambig', 'releasegroupdisambig', 'data_source',): + # Don't overwrite fields with empty values unless the + # field is explicitly allowed to be overwritten + clobber = field not in config['no_clobber'].get() value = getattr(album_info, field) - if value is not None: - item[field] = value + if value is None and not clobber: + continue + item[field] = value + if track_info.disctitle is not None: item.disctitle = track_info.disctitle diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 26babde55..0e6e01efa 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -30,6 +30,19 @@ import: bell: no set_fields: {} +no_clobber: + - albumtype + - label + - asin + - catalognum + - script + - language + - country + - albumstatus + - albumdisambig + - releasegroupdisambig + - data_source + clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] ignore_hidden: yes diff --git a/docs/changelog.rst b/docs/changelog.rst index b896604c2..746a3f5be 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,6 +63,9 @@ New features: provider: you can match tracks and albums using the Spotify database. Thanks to :user:`rhlahuja`. :bug:`3123` +* A new ``no_clobber`` configuration option allows setting a list of + fields not to be overwritten by empty values upon re-importing items. + :bug:`3132` Changes: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 57cb6c295..44f7dd576 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -303,6 +303,31 @@ The defaults look like this:: See :ref:`aunique` for more details. + +.. _no_clobber: + +no_clobber +~~~~~~~~~~ + +A list of fields that should not be overwritten by empty values when +re-importing items. + +The default is:: + + no_clobber: + - albumtype + - label + - asin + - catalognum + - script + - language + - country + - albumstatus + - albumdisambig + - releasegroupdisambig + - data_source + + .. _terminal_encoding: terminal_encoding From 37d918a19e55680cc73b31327b34f4aa96dc5b46 Mon Sep 17 00:00:00 2001 From: nichobi Date: Fri, 25 Jan 2019 15:48:49 +0100 Subject: [PATCH 079/149] docs: restore replaygain option for ffmpeg backend --- docs/plugins/replaygain.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 08c6b4a99..ad0e50e22 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -89,6 +89,10 @@ configuration file. The available options are: Default: ``no``. - **targetlevel**: A number of decibels for the target loudness level. Default: 89. +- **r128**: A space separated list of formats that will use ``R128_`` tags with + integer values instead of the common ``REPLAYGAIN_`` tags with floating point + values. Requires the "ffmpeg" backend. + Default: ``Opus``. These options only work with the "command" backend: From 2ef74999ea5e8e1a8aa1d6a0eefc699ded31e606 Mon Sep 17 00:00:00 2001 From: David Logie Date: Fri, 25 Jan 2019 17:11:15 +0000 Subject: [PATCH 080/149] Use .as_str_seq() instead of .get(). --- beets/autotag/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 6783929cd..f0c73d3c0 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -156,7 +156,7 @@ def apply_metadata(album_info, mapping): 'data_source',): # Don't overwrite fields with empty values unless the # field is explicitly allowed to be overwritten - clobber = field not in config['no_clobber'].get() + clobber = field not in config['no_clobber'].as_str_seq() value = getattr(album_info, field) if value is None and not clobber: continue From 604616050b845d5b0bb858f634e3c19ab295e18d Mon Sep 17 00:00:00 2001 From: David Logie Date: Sat, 26 Jan 2019 11:17:17 +0000 Subject: [PATCH 081/149] Address PR comments. - Rename config option to overwrite_null - Leave overwrite_null empty by default - Handle more potentially null fields for both album and tracks - Remove documentation and changelog entries for now --- beets/autotag/__init__.py | 68 ++++++++++++++++++++++----------------- beets/config_default.yaml | 16 ++------- docs/changelog.rst | 3 -- docs/reference/config.rst | 24 -------------- 4 files changed, 41 insertions(+), 70 deletions(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index f0c73d3c0..bf347b1e0 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -142,39 +142,47 @@ def apply_metadata(album_info, mapping): # Compilation flag. item.comp = album_info.va - # Miscellaneous metadata. - for field in ('albumtype', - 'label', - 'asin', - 'catalognum', - 'script', - 'language', - 'country', - 'albumstatus', - 'albumdisambig', - 'releasegroupdisambig', - 'data_source',): - # Don't overwrite fields with empty values unless the - # field is explicitly allowed to be overwritten - clobber = field not in config['no_clobber'].as_str_seq() + # Track alt. + item.track_alt = track_info.track_alt + + # Miscellaneous/nullable metadata. + misc_fields = { + 'album': ( + 'albumtype', + 'label', + 'asin', + 'catalognum', + 'script', + 'language', + 'country', + 'albumstatus', + 'albumdisambig', + 'releasegroupdisambig', + 'data_source', + ), + 'track': ( + 'disctitle', + 'lyricist', + 'media', + 'composer', + 'composer_sort', + 'arranger', + ) + } + + # Don't overwrite fields with empty values unless the + # field is explicitly allowed to be overwritten + for field in misc_fields['album']: + clobber = field in config['overwrite_null']['album'].as_str_seq() value = getattr(album_info, field) if value is None and not clobber: continue item[field] = value - if track_info.disctitle is not None: - item.disctitle = track_info.disctitle + for field in misc_fields['track']: + clobber = field in config['overwrite_null']['track'].as_str_seq() + value = getattr(track_info, field) + if value is None and not clobber: + continue + item[field] = value - if track_info.media is not None: - item.media = track_info.media - - if track_info.lyricist is not None: - item.lyricist = track_info.lyricist - if track_info.composer is not None: - item.composer = track_info.composer - if track_info.composer_sort is not None: - item.composer_sort = track_info.composer_sort - if track_info.arranger is not None: - item.arranger = track_info.arranger - - item.track_alt = track_info.track_alt diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 0e6e01efa..cf9ae6bf9 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -30,19 +30,6 @@ import: bell: no set_fields: {} -no_clobber: - - albumtype - - label - - asin - - catalognum - - script - - language - - country - - albumstatus - - albumdisambig - - releasegroupdisambig - - data_source - clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] ignore_hidden: yes @@ -66,6 +53,9 @@ aunique: disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig bracket: '[]' +overwrite_null: + album: [] + track: [] plugins: [] pluginpath: [] diff --git a/docs/changelog.rst b/docs/changelog.rst index 746a3f5be..b896604c2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,9 +63,6 @@ New features: provider: you can match tracks and albums using the Spotify database. Thanks to :user:`rhlahuja`. :bug:`3123` -* A new ``no_clobber`` configuration option allows setting a list of - fields not to be overwritten by empty values upon re-importing items. - :bug:`3132` Changes: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 44f7dd576..684dea20c 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -304,30 +304,6 @@ The defaults look like this:: See :ref:`aunique` for more details. -.. _no_clobber: - -no_clobber -~~~~~~~~~~ - -A list of fields that should not be overwritten by empty values when -re-importing items. - -The default is:: - - no_clobber: - - albumtype - - label - - asin - - catalognum - - script - - language - - country - - albumstatus - - albumdisambig - - releasegroupdisambig - - data_source - - .. _terminal_encoding: terminal_encoding From 19db67c6425c5931c28919205b76e4c4628254b3 Mon Sep 17 00:00:00 2001 From: David Logie Date: Sat, 26 Jan 2019 18:35:37 +0000 Subject: [PATCH 082/149] Remove extra trailing blank line. --- beets/autotag/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index bf347b1e0..a71b9b0a6 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -185,4 +185,3 @@ def apply_metadata(album_info, mapping): if value is None and not clobber: continue item[field] = value - From 2b82831b7b081128c788423e9ce1382db4b66c37 Mon Sep 17 00:00:00 2001 From: Iris Wildthyme <46726098+wildthyme@users.noreply.github.com> Date: Wed, 30 Jan 2019 16:36:46 -0500 Subject: [PATCH 083/149] added --nocopy support --- beetsplug/ipfs.py | 11 +++++++++-- docs/plugins/ipfs.rst | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/beetsplug/ipfs.py b/beetsplug/ipfs.py index 9a9d6aa50..90ba5fdd0 100644 --- a/beetsplug/ipfs.py +++ b/beetsplug/ipfs.py @@ -32,6 +32,7 @@ class IPFSPlugin(BeetsPlugin): super(IPFSPlugin, self).__init__() self.config.add({ 'auto': True, + 'nocopy': False, }) if self.config['auto']: @@ -116,7 +117,10 @@ class IPFSPlugin(BeetsPlugin): self._log.info('Adding {0} to ipfs', album_dir) - cmd = "ipfs add -q -r".split() + if self.config['nocopy']: + cmd = "ipfs add --nocopy -q -r".split() + else: + cmd = "ipfs add -q -r".split() cmd.append(album_dir) try: output = util.command_output(cmd).split() @@ -174,7 +178,10 @@ class IPFSPlugin(BeetsPlugin): with tempfile.NamedTemporaryFile() as tmp: self.ipfs_added_albums(lib, tmp.name) try: - cmd = "ipfs add -q ".split() + if self.config['nocopy']: + cmd = "ipfs add --nocopy -q ".split() + else: + cmd = "ipfs add -q ".split() cmd.append(tmp.name) output = util.command_output(cmd) except (OSError, subprocess.CalledProcessError) as err: diff --git a/docs/plugins/ipfs.rst b/docs/plugins/ipfs.rst index a9b5538df..141143ae7 100644 --- a/docs/plugins/ipfs.rst +++ b/docs/plugins/ipfs.rst @@ -70,3 +70,5 @@ Configuration The ipfs plugin will automatically add imported albums to ipfs and add those hashes to the database. This can be turned off by setting the ``auto`` option in the ``ipfs:`` section of the config to ``no``. + +If the setting ``nocopy`` is true (defaults false) then the plugin will pass the ``--nocopy`` option when adding things to ipfs. If the filestore option of ipfs is enabled this will mean files are neither removed from beets nor copied somewhere else. From 367bb3026fa7b558bde29d59179afe7345f7c758 Mon Sep 17 00:00:00 2001 From: Iris Wildthyme <46726098+wildthyme@users.noreply.github.com> Date: Wed, 30 Jan 2019 17:13:12 -0500 Subject: [PATCH 084/149] added to changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b896604c2..242279583 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -63,6 +63,8 @@ New features: provider: you can match tracks and albums using the Spotify database. Thanks to :user:`rhlahuja`. :bug:`3123` +* :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which passes that flag to ipfs. + THanks to :user:`wildthyme`. Changes: From 768770d56158224ee5073dbe26f84b227ad714ea Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 31 Jan 2019 00:15:42 +0000 Subject: [PATCH 085/149] Fix incorrect indentation --- beets/art.py | 4 ++-- beetsplug/discogs.py | 2 +- beetsplug/filefilter.py | 4 ++-- beetsplug/hook.py | 36 ++++++++++++++++++------------------ beetsplug/replaygain.py | 8 ++++---- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/beets/art.py b/beets/art.py index 979a6f722..84c3a02d5 100644 --- a/beets/art.py +++ b/beets/art.py @@ -60,8 +60,8 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None, log.info(u'Image not similar; skipping.') return if ifempty and get_art(log, item): - log.info(u'media file already contained art') - return + log.info(u'media file already contained art') + return if maxwidth and not as_album: imagepath = resize_image(log, imagepath, maxwidth) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index eeb87d311..5b11b9617 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -286,7 +286,7 @@ class DiscogsPlugin(BeetsPlugin): if va: artist = config['va_name'].as_str() if catalogno == 'none': - catalogno = None + catalogno = None # Explicitly set the `media` for the tracks, since it is expected by # `autotag.apply_metadata`, and set `medium_total`. for track in tracks: diff --git a/beetsplug/filefilter.py b/beetsplug/filefilter.py index 23dac5746..ea521d5f6 100644 --- a/beetsplug/filefilter.py +++ b/beetsplug/filefilter.py @@ -43,8 +43,8 @@ class FileFilterPlugin(BeetsPlugin): bytestring_path(self.config['album_path'].get())) if 'singleton_path' in self.config: - self.path_singleton_regex = re.compile( - bytestring_path(self.config['singleton_path'].get())) + self.path_singleton_regex = re.compile( + bytestring_path(self.config['singleton_path'].get())) def import_task_created_event(self, session, task): if task.items and len(task.items) > 0: diff --git a/beetsplug/hook.py b/beetsplug/hook.py index b6270fd50..de44c1b81 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -91,28 +91,28 @@ class HookPlugin(BeetsPlugin): def create_and_register_hook(self, event, command): def hook_function(**kwargs): - if command is None or len(command) == 0: - self._log.error('invalid command "{0}"', command) - return + if command is None or len(command) == 0: + self._log.error('invalid command "{0}"', command) + return - # Use a string formatter that works on Unicode strings. - if six.PY2: - formatter = CodingFormatter(arg_encoding()) - else: - formatter = string.Formatter() + # Use a string formatter that works on Unicode strings. + if six.PY2: + formatter = CodingFormatter(arg_encoding()) + else: + formatter = string.Formatter() - command_pieces = shlex_split(command) + command_pieces = shlex_split(command) - for i, piece in enumerate(command_pieces): - command_pieces[i] = formatter.format(piece, event=event, - **kwargs) + for i, piece in enumerate(command_pieces): + command_pieces[i] = formatter.format(piece, event=event, + **kwargs) - self._log.debug(u'running command "{0}" for event {1}', - u' '.join(command_pieces), event) + self._log.debug(u'running command "{0}" for event {1}', + u' '.join(command_pieces), event) - try: - subprocess.Popen(command_pieces).wait() - except OSError as exc: - self._log.error(u'hook for {0} failed: {1}', event, exc) + try: + subprocess.Popen(command_pieces).wait() + except OSError as exc: + self._log.error(u'hook for {0} failed: {1}', event, exc) self.register_listener(event, hook_function) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index ac45aa4f8..4168c61b9 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -935,10 +935,10 @@ class ReplayGainPlugin(BeetsPlugin): if (any([self.should_use_r128(item) for item in album.items()]) and not all(([self.should_use_r128(item) for item in album.items()]))): - raise ReplayGainError( - u"Mix of ReplayGain and EBU R128 detected" - u" for some tracks in album {0}".format(album) - ) + raise ReplayGainError( + u"Mix of ReplayGain and EBU R128 detected" + u" for some tracks in album {0}".format(album) + ) if any([self.should_use_r128(item) for item in album.items()]): if self.r128_backend_instance == '': From 2bc04bb60570f3c5a7b4e327787d2441250e5a36 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 31 Jan 2019 00:15:55 +0000 Subject: [PATCH 086/149] Use "==" when comparing strings --- beetsplug/subsonicupdate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/subsonicupdate.py b/beetsplug/subsonicupdate.py index 93c47e2de..bb9e8a952 100644 --- a/beetsplug/subsonicupdate.py +++ b/beetsplug/subsonicupdate.py @@ -78,7 +78,7 @@ class SubsonicUpdate(BeetsPlugin): 'v': '1.15.0', # Subsonic 6.1 and newer. 'c': 'beets' } - if contextpath is '/': + if contextpath == '/': contextpath = '' url = "http://{}:{}{}/rest/startScan".format(host, port, contextpath) response = requests.post(url, params=payload) From 2564db905581bb14403bf6259278e05631536ace Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Thu, 31 Jan 2019 00:16:11 +0000 Subject: [PATCH 087/149] Lock pep8-naming to 0.7.x --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 154cd7655..eeacf2af5 100644 --- a/tox.ini +++ b/tox.ini @@ -31,7 +31,7 @@ deps = flake8-coding flake8-future-import flake8-blind-except - pep8-naming + pep8-naming~=0.7.0 files = beets beetsplug beet test setup.py docs [testenv] From c20c3a439858b7584104835abd250887e36a222e Mon Sep 17 00:00:00 2001 From: Iris Wildthyme <46726098+wildthyme@users.noreply.github.com> Date: Fri, 1 Feb 2019 09:36:51 -0500 Subject: [PATCH 088/149] fixed typo --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 242279583..9cab4a1e0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -64,7 +64,7 @@ New features: Thanks to :user:`rhlahuja`. :bug:`3123` * :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which passes that flag to ipfs. - THanks to :user:`wildthyme`. + Thanks to :user:`wildthyme`. Changes: From 77fd5ee5488813654cf9881b460865eff55bf2f0 Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 8 Feb 2019 00:05:07 +0100 Subject: [PATCH 089/149] keep discogs requests below rate limit --- beetsplug/discogs.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 5b11b9617..4d25fca50 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -61,6 +61,8 @@ class DiscogsPlugin(BeetsPlugin): self.config['user_token'].redact = True self.discogs_client = None self.register_listener('import_begin', self.setup) + self.rate_limit_per_minute = 25 + self.last_request_timestamp = 0 def setup(self, session=None): """Create the `discogs_client` field. Authenticate if necessary. @@ -71,6 +73,7 @@ class DiscogsPlugin(BeetsPlugin): # Try using a configured user token (bypassing OAuth login). user_token = self.config['user_token'].as_str() if user_token: + self.rate_limit_per_minute = 60 self.discogs_client = Client(USER_AGENT, user_token=user_token) return @@ -88,6 +91,20 @@ class DiscogsPlugin(BeetsPlugin): self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret) + def _time_to_next_request(self): + seconds_between_requests = 60 / self.rate_limit_per_minute + seconds_since_last_request = time.time() - self.last_request_timestamp + seconds_to_wait = seconds_between_requests - seconds_since_last_request + if seconds_to_wait > 0: + return seconds_to_wait + return 0 + + def wait_for_rate_limiter(self): + time_to_next_request = self._time_to_next_request() + if time_to_next_request > 0: + self._log.debug('hit rate limit, waiting for {0} seconds', time_to_next_request) + time.sleep(time_to_next_request) + def reset_auth(self): """Delete token file & redo the auth steps. """ @@ -206,9 +223,13 @@ class DiscogsPlugin(BeetsPlugin): # Strip medium information from query, Things like "CD1" and "disk 1" # can also negate an otherwise positive result. query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query) + + self.wait_for_rate_limiter() try: releases = self.discogs_client.search(query, type='release').page(1) + self.last_request_timestamp = time.time() + except CONNECTION_ERRORS: self._log.debug(u"Communication error while searching for {0!r}", query, exc_info=True) @@ -222,8 +243,11 @@ class DiscogsPlugin(BeetsPlugin): """ self._log.debug(u'Searching for master release {0}', master_id) result = Master(self.discogs_client, {'id': master_id}) + + self.wait_for_rate_limiter() try: year = result.fetch('year') + self.last_request_timestamp = time.time() return year except DiscogsAPIError as e: if e.status_code != 404: From 9bc3898951886a42f57f03802fc238e6b599a26d Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 8 Feb 2019 01:02:33 +0100 Subject: [PATCH 090/149] add request_finished function, rename wait_for_rate_limiter to request_start, add doc and changelog --- beetsplug/discogs.py | 24 +++++++++++++++--------- docs/changelog.rst | 3 ++- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 4d25fca50..b9a832c82 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -73,6 +73,7 @@ class DiscogsPlugin(BeetsPlugin): # Try using a configured user token (bypassing OAuth login). user_token = self.config['user_token'].as_str() if user_token: + # rate limit for authenticated users is 60 per minute self.rate_limit_per_minute = 60 self.discogs_client = Client(USER_AGENT, user_token=user_token) return @@ -95,16 +96,22 @@ class DiscogsPlugin(BeetsPlugin): seconds_between_requests = 60 / self.rate_limit_per_minute seconds_since_last_request = time.time() - self.last_request_timestamp seconds_to_wait = seconds_between_requests - seconds_since_last_request - if seconds_to_wait > 0: - return seconds_to_wait - return 0 + return seconds_to_wait - def wait_for_rate_limiter(self): + def request_start(self): + """wait for rate limit if needed + """ time_to_next_request = self._time_to_next_request() if time_to_next_request > 0: - self._log.debug('hit rate limit, waiting for {0} seconds', time_to_next_request) + self._log.debug('hit rate limit, waiting for {0} seconds', + time_to_next_request) time.sleep(time_to_next_request) + def request_finished(self): + """update timestamp for rate limiting + """ + self.last_request_timestamp = time.time() + def reset_auth(self): """Delete token file & redo the auth steps. """ @@ -224,11 +231,10 @@ class DiscogsPlugin(BeetsPlugin): # can also negate an otherwise positive result. query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query) - self.wait_for_rate_limiter() + self.request_start() try: releases = self.discogs_client.search(query, type='release').page(1) - self.last_request_timestamp = time.time() except CONNECTION_ERRORS: self._log.debug(u"Communication error while searching for {0!r}", @@ -244,10 +250,10 @@ class DiscogsPlugin(BeetsPlugin): self._log.debug(u'Searching for master release {0}', master_id) result = Master(self.discogs_client, {'id': master_id}) - self.wait_for_rate_limiter() + self.request_start() try: year = result.fetch('year') - self.last_request_timestamp = time.time() + self.request_finished() return year except DiscogsAPIError as e: if e.status_code != 404: diff --git a/docs/changelog.rst b/docs/changelog.rst index 9cab4a1e0..19a9aea32 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -65,7 +65,8 @@ New features: :bug:`3123` * :doc:`/plugins/ipfs`: The plugin now supports a ``nocopy`` option which passes that flag to ipfs. Thanks to :user:`wildthyme`. - +* :doc:`/plugins/discogs`: The plugin has rate limiting for the discogs API now. + :bug:`3081` Changes: From 5ace66775714c4d50cd04826b7a3bbb380731d5a Mon Sep 17 00:00:00 2001 From: jan Date: Fri, 8 Feb 2019 01:09:07 +0100 Subject: [PATCH 091/149] add forgotten request_finished --- beetsplug/discogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index b9a832c82..a5208f4f8 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -235,6 +235,7 @@ class DiscogsPlugin(BeetsPlugin): try: releases = self.discogs_client.search(query, type='release').page(1) + self.request_finished() except CONNECTION_ERRORS: self._log.debug(u"Communication error while searching for {0!r}", From f54042f194a91b0590d798338a6e43b8a3c4d20d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 8 Feb 2019 18:18:30 -0800 Subject: [PATCH 092/149] Make a comment into a full sentence --- beetsplug/discogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index a5208f4f8..d7797e409 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -73,7 +73,8 @@ class DiscogsPlugin(BeetsPlugin): # Try using a configured user token (bypassing OAuth login). user_token = self.config['user_token'].as_str() if user_token: - # rate limit for authenticated users is 60 per minute + # The rate limit for authenticated users goes up to 60 + # requests per minute. self.rate_limit_per_minute = 60 self.discogs_client = Client(USER_AGENT, user_token=user_token) return From 90904014890521fd6ebfaf83a083627c359f171e Mon Sep 17 00:00:00 2001 From: Vinicius Massuchetto Date: Wed, 13 Feb 2019 07:48:14 -0200 Subject: [PATCH 093/149] added beets-ydl plugin --- docs/plugins/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 6bf50e227..773217891 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -254,6 +254,8 @@ Here are a few of the plugins written by the beets community: * `beets-barcode`_ lets you scan or enter barcodes for physical media to search for their metadata. +* `beets-ydl`_ download audio from youtube-dl sources and import into beets + .. _beets-barcode: https://github.com/8h2a/beets-barcode .. _beets-check: https://github.com/geigerzaehler/beets-check .. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts @@ -273,3 +275,4 @@ Here are a few of the plugins written by the beets community: .. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets .. _beets-usertag: https://github.com/igordertigor/beets-usertag .. _beets-popularity: https://github.com/abba23/beets-popularity +.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl From bc5b15f27757c92a410956e7e4f388aeabd82900 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 14 Feb 2019 15:52:55 +0100 Subject: [PATCH 094/149] library: Pass try_write() kwargs directly to write() This avoids duplication of the kwargs and their default values. --- beets/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beets/library.py b/beets/library.py index 1e46fe5ef..82ba4141d 100644 --- a/beets/library.py +++ b/beets/library.py @@ -657,14 +657,14 @@ class Item(LibModel): self.mtime = self.current_mtime() plugins.send('after_write', item=self, path=path) - def try_write(self, path=None, tags=None): + def try_write(self, *args, **kwargs): """Calls `write()` but catches and logs `FileOperationError` exceptions. Returns `False` an exception was caught and `True` otherwise. """ try: - self.write(path, tags) + self.write(*args, **kwargs) return True except FileOperationError as exc: log.error(u"{0}", exc) From 305bb640862d978fdeee0aa09c768ac95acd14c7 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 14 Feb 2019 15:53:32 +0100 Subject: [PATCH 095/149] library: Allow overriding global id3v23 option in write() --- beets/library.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/beets/library.py b/beets/library.py index 82ba4141d..16db1e974 100644 --- a/beets/library.py +++ b/beets/library.py @@ -611,7 +611,7 @@ class Item(LibModel): self.path = read_path - def write(self, path=None, tags=None): + def write(self, path=None, tags=None, id3v23=None): """Write the item's metadata to a media file. All fields in `_media_fields` are written to disk according to @@ -623,6 +623,9 @@ class Item(LibModel): `tags` is a dictionary of additional metadata the should be written to the file. (These tags need not be in `_media_fields`.) + `id3v23` will override the global `id3v23` config option if it is + set to something other than `None`. + Can raise either a `ReadError` or a `WriteError`. """ if path is None: @@ -630,6 +633,9 @@ class Item(LibModel): else: path = normpath(path) + if id3v23 is None: + id3v23 = beets.config['id3v23'].get(bool) + # Get the data to write to the file. item_tags = dict(self) item_tags = {k: v for k, v in item_tags.items() @@ -640,8 +646,7 @@ class Item(LibModel): # Open the file. try: - mediafile = MediaFile(syspath(path), - id3v23=beets.config['id3v23'].get(bool)) + mediafile = MediaFile(syspath(path), id3v23=id3v23) except UnreadableFileError as exc: raise ReadError(path, exc) From 53b63443fbb1a48341025c941b479e401f2c13d4 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 14 Feb 2019 23:32:40 +0100 Subject: [PATCH 096/149] art: Allow overriding id3v23 in embed_item() --- beets/art.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beets/art.py b/beets/art.py index 979a6f722..4a9ea58c7 100644 --- a/beets/art.py +++ b/beets/art.py @@ -51,7 +51,8 @@ def get_art(log, item): def embed_item(log, item, imagepath, maxwidth=None, itempath=None, - compare_threshold=0, ifempty=False, as_album=False): + compare_threshold=0, ifempty=False, as_album=False, + id3v23=None): """Embed an image into the item's media file. """ # Conditions and filters. @@ -80,7 +81,7 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None, image.mime_type) return - item.try_write(path=itempath, tags={'images': [image]}) + item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23) def embed_album(log, album, maxwidth=None, quiet=False, From 7afeb9b2ace75c3d9d00e5f6858989fa763b755a Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 14 Feb 2019 19:18:02 +0100 Subject: [PATCH 097/149] convert: Add id3v23 config option to convert plugin --- beetsplug/convert.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/beetsplug/convert.py b/beetsplug/convert.py index 3c9080d1f..303563a7a 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -116,6 +116,7 @@ class ConvertPlugin(BeetsPlugin): u'pretend': False, u'threads': util.cpu_count(), u'format': u'mp3', + u'id3v23': u'inherit', u'formats': { u'aac': { u'command': u'ffmpeg -i $source -y -vn -acodec aac ' @@ -316,8 +317,12 @@ class ConvertPlugin(BeetsPlugin): if pretend: continue + id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit']) + if id3v23 == 'inherit': + id3v23 = None + # Write tags from the database to the converted file. - item.try_write(path=converted) + item.try_write(path=converted, id3v23=id3v23) if keep_new: # If we're keeping the transcoded file, read it again (after @@ -332,7 +337,7 @@ class ConvertPlugin(BeetsPlugin): self._log.debug(u'embedding album art from {}', util.displayable_path(album.artpath)) art.embed_item(self._log, item, album.artpath, - itempath=converted) + itempath=converted, id3v23=id3v23) if keep_new: plugins.send('after_convert', item=item, From 057904648732fb949886b63ddf4bd03d5e9d56ad Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 14 Feb 2019 23:37:35 +0100 Subject: [PATCH 098/149] docs: Add new id3v23 config option to convert plugin documentation --- docs/plugins/convert.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index a631f7891..1a487cdee 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -68,6 +68,8 @@ file. The available options are: - **dest**: The directory where the files will be converted (or copied) to. Default: none. - **embed**: Embed album art in converted items. Default: ``yes``. +- **id3v23**: Can be used to override the global ``id3v23`` option. Default: + ``inherit``. - **max_bitrate**: All lossy files with a higher bitrate will be transcoded and those with a lower bitrate will simply be copied. Note that this does not guarantee that all converted files will have a lower From 72f837b0cc3ca3ceacaa3e41203db80e40f76de2 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 13:35:26 +0100 Subject: [PATCH 099/149] docs: Add changelog entry regarding convert plugin's id3v23 option --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e9b751fe..b44c0e817 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -37,6 +37,10 @@ New features: relevant releases according to the :ref:`preferred` configuration options. Thanks to :user:`archer4499`. :bug:`3017` +* :doc:`/plugins/convert`: The plugin now has a ``id3v23`` option that allows + to override the global ``id3v23`` option. + Thanks to :user:`Holzhaus`. + :bug:`3104` * A new ``aunique`` configuration option allows setting default options for the :ref:`aunique` template function. * The ``albumdisambig`` field no longer includes the MusicBrainz release group From 9ca80dd3fd4199f14c571ae16a1a8a3eb57c6510 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Fri, 15 Feb 2019 23:56:21 +0000 Subject: [PATCH 100/149] Lock munkres to 1.0.x --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 19c03041a..cda52f360 100755 --- a/setup.py +++ b/setup.py @@ -88,7 +88,7 @@ setup( install_requires=[ 'six>=1.9', 'mutagen>=1.33', - 'munkres', + 'munkres~=1.0.0', 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', From a80a07f093c3a5f77446bd1b6e34bbc4d6d581e3 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Tue, 10 Jan 2017 14:42:15 +0000 Subject: [PATCH 101/149] playlist: Add playlist plugin Adds M3U playlist support as a query to beets and thus partially resolves issue #123. The implementation is heavily based on #2380 by Robin McCorkell. It supports referencing playlists by absolute path: $ beet ls playlist:/path/to/someplaylist.m3u It also supports referencing playlists by name. The playlist is then seached in the playlist_dir and the ".m3u" extension is appended to the name: $ beet ls playlist:anotherplaylist The configuration for the plugin looks like this: playlist: relative_to: library playlist_dir: /path/to/playlists The relative_to option specifies how relative paths in playlists are handled. By default, paths are relative to the "library" directory. It also possible to make them relative to the "playlist" or set the option or set it to a fixed path. --- beetsplug/playlist.py | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 beetsplug/playlist.py diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py new file mode 100644 index 000000000..624791ee4 --- /dev/null +++ b/beetsplug/playlist.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +import os +import beets + + +class PlaylistQuery(beets.dbcore.FieldQuery): + """Matches files listed by a playlist file. + """ + def __init__(self, field, pattern, fast=False): + super(PlaylistQuery, self).__init__(field, pattern, fast) + config = beets.config['playlist'] + + # Get the full path to the playlist + if os.path.isabs(beets.util.syspath(pattern)): + playlist_path = pattern + else: + playlist_path = os.path.abspath(os.path.join( + config['playlist_dir'].as_filename(), + '{0}.m3u'.format(pattern), + )) + + if config['relative_to'].get() == 'library': + relative_to = beets.config['directory'].as_filename() + elif config['relative_to'].get() == 'playlist': + relative_to = os.path.dirname(playlist_path) + else: + relative_to = config['relative_to'].as_filename() + relative_to = beets.util.bytestring_path(relative_to) + + self.paths = [] + with open(beets.util.syspath(playlist_path), 'rb') as f: + for line in f: + if line[0] == '#': + # ignore comments, and extm3u extension + continue + + self.paths.append(beets.util.normpath( + os.path.join(relative_to, line.rstrip()) + )) + + def match(self, item): + return item.path in self.paths + + +class PlaylistType(beets.dbcore.types.String): + """Custom type for playlist query. + """ + query = PlaylistQuery + + +class PlaylistPlugin(beets.plugins.BeetsPlugin): + item_types = {'playlist': PlaylistType()} + + def __init__(self): + super(PlaylistPlugin, self).__init__() + self.config.add({ + 'playlist_dir': '.', + 'relative_to': 'library', + }) From 19b92e1199439017e05bf79408a863a91c34972a Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 18:44:58 +0100 Subject: [PATCH 102/149] playlist: Improve speed in PlaylistQuery class Implement the col_clause method for faster, sqlite-based querying. This will only make a difference if the "fast" kwarg is set to True. --- beetsplug/playlist.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 624791ee4..654e3e1d0 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -19,7 +19,7 @@ import beets class PlaylistQuery(beets.dbcore.FieldQuery): """Matches files listed by a playlist file. """ - def __init__(self, field, pattern, fast=False): + def __init__(self, field, pattern, fast=True): super(PlaylistQuery, self).__init__(field, pattern, fast) config = beets.config['playlist'] @@ -51,6 +51,14 @@ class PlaylistQuery(beets.dbcore.FieldQuery): os.path.join(relative_to, line.rstrip()) )) + def col_clause(self): + if not self.paths: + # Playlist is empty + return '0', () + clause = 'BYTELOWER(path) IN ({0})'.format( + ', '.join('BYTELOWER(?)' for path in self.paths)) + return clause, (beets.library.BLOB_TYPE(p) for p in self.paths) + def match(self, item): return item.path in self.paths From cc501be2d9c9442eeaffb7e9681b11b58671abc7 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 23:06:36 +0100 Subject: [PATCH 103/149] docs: Add documentation for the playlist plugin --- docs/plugins/index.rst | 2 ++ docs/plugins/playlist.rst | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/plugins/playlist.rst diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 6bf50e227..e51354dac 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -81,6 +81,7 @@ like this:: mpdupdate permissions play + playlist plexupdate random replaygain @@ -158,6 +159,7 @@ Interoperability * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library changes. * :doc:`play`: Play beets queries in your music player. +* :doc:`playlist`: Use M3U playlists tp query the beets library. * :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library changes. * :doc:`smartplaylist`: Generate smart playlists based on beets queries. diff --git a/docs/plugins/playlist.rst b/docs/plugins/playlist.rst new file mode 100644 index 000000000..0a4e797c3 --- /dev/null +++ b/docs/plugins/playlist.rst @@ -0,0 +1,37 @@ +Smart Playlist Plugin +===================== + +``playlist`` is a plugin to use playlists in m3u format. + +To use it, enable the ``playlist`` plugin in your configuration +(see :ref:`using-plugins`). +Then configure your playlists like this:: + + playlist: + relative_to: ~/Music + playlist_dir: ~/.mpd/playlists + +It is possible to query the library based on a playlist by speicifying its +absolute path:: + + $ beet ls playlist:/path/to/someplaylist.m3u + +The plugin also supports referencing playlists by name. The playlist is then +seached in the playlist_dir and the ".m3u" extension is appended to the +name:: + + $ beet ls playlist:anotherplaylist + +Configuration +------------- + +To configure the plugin, make a ``smartplaylist:`` section in your +configuration file. In addition to the ``playlists`` described above, the +other configuration options are: + +- **playlist_dir**: Where to read playlist files from. + Default: The current working directory (i.e., ``'.'``). +- **relative_to**: Interpret paths in the playlist files relative to a base + directory. It is also possible to set it to ``playlist`` to use the + playlist's parent directory as base directory. + Default: ``library`` From d78bade30cfe39dcc0207330a31fa34195ad11b8 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 23:07:19 +0100 Subject: [PATCH 104/149] docs: Add playlist plugin to the changelog --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2e9b751fe..a3b50af05 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,6 +14,10 @@ New features: issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` +* :doc:`/plugins/playlist`: Add a plugin that can query the beets library using + M3U playlists. + Thanks to :user:`Holzhaus` and :user:`Xenopathic`. + :bug:`123` * Added whitespace padding to missing tracks dialog to improve readability. Thanks to :user:`jams2`. :bug:`2962` From 0988a2a18688a8b8e07d94e1609405c17bbe717d Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 19:51:00 +0100 Subject: [PATCH 105/149] test: Add test suite for the playlist plugin --- test/test_playlist.py | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 test/test_playlist.py diff --git a/test/test_playlist.py b/test/test_playlist.py new file mode 100644 index 000000000..176249134 --- /dev/null +++ b/test/test_playlist.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from __future__ import division, absolute_import, print_function + +import os +import tempfile +import unittest + +from test import _common +from test import helper + +import beets + + +class PlaylistTest(unittest.TestCase, helper.TestHelper): + def setUp(self): + self.setup_beets() + self.lib = beets.library.Library(':memory:') + + i1 = _common.item() + i1.path = beets.util.normpath('/a/b/c.mp3') + i1.title = u'some item' + i1.album = u'some album' + self.lib.add(i1) + self.lib.add_album([i1]) + + i2 = _common.item() + i2.path = beets.util.normpath('/d/e/f.mp3') + i2.title = 'another item' + i2.album = 'another album' + self.lib.add(i2) + self.lib.add_album([i2]) + + i3 = _common.item() + i3.path = beets.util.normpath('/x/y/z.mp3') + i3.title = 'yet another item' + i3.album = 'yet another album' + self.lib.add(i3) + self.lib.add_album([i3]) + + self.playlist_dir = tempfile.TemporaryDirectory() + with open(os.path.join(self.playlist_dir.name, 'test.m3u'), 'w') as f: + f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) + f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) + + self.config['directory'] = '/' + self.config['playlist']['relative_to'] = 'library' + self.config['playlist']['playlist_dir'] = self.playlist_dir.name + self.load_plugins('playlist') + + def tearDown(self): + self.unload_plugins() + self.playlist_dir.cleanup() + self.teardown_beets() + + def test_query_name(self): + q = u'playlist:test' + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_query_path(self): + q = u'playlist:{0}/test.m3u'.format(self.playlist_dir.name) + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') From f9f2fa0e266adb146b79542195c0761f59f0292f Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 15:13:55 +0100 Subject: [PATCH 106/149] playlist: Restructure playlist reading code and add error handling --- beetsplug/playlist.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 654e3e1d0..a6ab8d18b 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -13,6 +13,7 @@ # included in all copies or substantial portions of the Software. import os +import fnmatch import beets @@ -24,24 +25,33 @@ class PlaylistQuery(beets.dbcore.FieldQuery): config = beets.config['playlist'] # Get the full path to the playlist - if os.path.isabs(beets.util.syspath(pattern)): - playlist_path = pattern - else: - playlist_path = os.path.abspath(os.path.join( + playlist_paths = ( + pattern, + os.path.abspath(os.path.join( config['playlist_dir'].as_filename(), '{0}.m3u'.format(pattern), - )) - - if config['relative_to'].get() == 'library': - relative_to = beets.config['directory'].as_filename() - elif config['relative_to'].get() == 'playlist': - relative_to = os.path.dirname(playlist_path) - else: - relative_to = config['relative_to'].as_filename() - relative_to = beets.util.bytestring_path(relative_to) + )), + ) self.paths = [] - with open(beets.util.syspath(playlist_path), 'rb') as f: + for playlist_path in playlist_paths: + if not fnmatch.fnmatch(playlist_path, '*.[mM]3[uU]'): + # This is not am M3U playlist, skip this candidate + continue + + try: + f = open(beets.util.syspath(playlist_path), mode='rb') + except OSError: + continue + + if config['relative_to'].get() == 'library': + relative_to = beets.config['directory'].as_filename() + elif config['relative_to'].get() == 'playlist': + relative_to = os.path.dirname(playlist_path) + else: + relative_to = config['relative_to'].as_filename() + relative_to = beets.util.bytestring_path(relative_to) + for line in f: if line[0] == '#': # ignore comments, and extm3u extension @@ -50,6 +60,8 @@ class PlaylistQuery(beets.dbcore.FieldQuery): self.paths.append(beets.util.normpath( os.path.join(relative_to, line.rstrip()) )) + f.close() + break def col_clause(self): if not self.paths: From d52dcdd48ff10422cf9734d2980156a2832c8d7f Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 15:16:44 +0100 Subject: [PATCH 107/149] test: Add playlist testcases for nonexisting playlists --- test/test_playlist.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test_playlist.py b/test/test_playlist.py index 176249134..2c7c89805 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -82,6 +82,16 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): u'another item', ])) + def test_query_name_nonexisting(self): + q = u'playlist:nonexisting'.format(self.playlist_dir.name) + results = self.lib.items(q) + self.assertEqual(set(results), set()) + + def test_query_path_nonexisting(self): + q = u'playlist:{0}/nonexisting.m3u'.format(self.playlist_dir.name) + results = self.lib.items(q) + self.assertEqual(set(results), set()) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 34cdeeefb72b23dc00e291ecf1934030089417b6 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 15:35:30 +0100 Subject: [PATCH 108/149] docs: Reword documentation of playlist plugin's relative_to option --- docs/plugins/playlist.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plugins/playlist.rst b/docs/plugins/playlist.rst index 0a4e797c3..1156e7f77 100644 --- a/docs/plugins/playlist.rst +++ b/docs/plugins/playlist.rst @@ -32,6 +32,7 @@ other configuration options are: - **playlist_dir**: Where to read playlist files from. Default: The current working directory (i.e., ``'.'``). - **relative_to**: Interpret paths in the playlist files relative to a base - directory. It is also possible to set it to ``playlist`` to use the - playlist's parent directory as base directory. + directory. Instead of setting it to a fixed path, it is also possible to + set it to ``playlist`` to use the playlist's parent directory or to + ``library`` to use the library directory. Default: ``library`` From d4039be9c07b118b619d0353fe844b7ea867be01 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 15:39:47 +0100 Subject: [PATCH 109/149] test: Get rid of TemporaryDirectory to restore Python 2.7 compatibility --- test/test_playlist.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 2c7c89805..abae5d969 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -16,6 +16,7 @@ from __future__ import division, absolute_import, print_function import os +import shutil import tempfile import unittest @@ -51,19 +52,19 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.lib.add(i3) self.lib.add_album([i3]) - self.playlist_dir = tempfile.TemporaryDirectory() - with open(os.path.join(self.playlist_dir.name, 'test.m3u'), 'w') as f: + self.playlist_dir = tempfile.mkdtemp() + with open(os.path.join(self.playlist_dir, 'test.m3u'), 'w') as f: f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) self.config['directory'] = '/' self.config['playlist']['relative_to'] = 'library' - self.config['playlist']['playlist_dir'] = self.playlist_dir.name + self.config['playlist']['playlist_dir'] = self.playlist_dir self.load_plugins('playlist') def tearDown(self): self.unload_plugins() - self.playlist_dir.cleanup() + shutil.rmtree(self.playlist_dir) self.teardown_beets() def test_query_name(self): @@ -75,7 +76,7 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): ])) def test_query_path(self): - q = u'playlist:{0}/test.m3u'.format(self.playlist_dir.name) + q = u'playlist:{0}/test.m3u'.format(self.playlist_dir) results = self.lib.items(q) self.assertEqual(set([i.title for i in results]), set([ u'some item', @@ -83,12 +84,12 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): ])) def test_query_name_nonexisting(self): - q = u'playlist:nonexisting'.format(self.playlist_dir.name) + q = u'playlist:nonexisting'.format(self.playlist_dir) results = self.lib.items(q) self.assertEqual(set(results), set()) def test_query_path_nonexisting(self): - q = u'playlist:{0}/nonexisting.m3u'.format(self.playlist_dir.name) + q = u'playlist:{0}/nonexisting.m3u'.format(self.playlist_dir) results = self.lib.items(q) self.assertEqual(set(results), set()) From 32b6df046e242c437ddbebb71be1398b68c21293 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 15:57:40 +0100 Subject: [PATCH 110/149] test: Don't use unix-only paths in playlist plugin testcase --- test/test_playlist.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index abae5d969..f10076220 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -31,22 +31,33 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.setup_beets() self.lib = beets.library.Library(':memory:') + self.music_dir = os.path.expanduser('~/Music') + i1 = _common.item() - i1.path = beets.util.normpath('/a/b/c.mp3') + i1.path = beets.util.normpath(os.path.join( + self.music_dir, + 'a/b/c.mp3', + )) i1.title = u'some item' i1.album = u'some album' self.lib.add(i1) self.lib.add_album([i1]) i2 = _common.item() - i2.path = beets.util.normpath('/d/e/f.mp3') + i2.path = beets.util.normpath(os.path.join( + self.music_dir, + 'd/e/f.mp3', + )) i2.title = 'another item' i2.album = 'another album' self.lib.add(i2) self.lib.add_album([i2]) i3 = _common.item() - i3.path = beets.util.normpath('/x/y/z.mp3') + i3.path = beets.util.normpath(os.path.join( + self.music_dir, + 'x/y/z.mp3', + )) i3.title = 'yet another item' i3.album = 'yet another album' self.lib.add(i3) @@ -57,7 +68,7 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) - self.config['directory'] = '/' + self.config['directory'] = self.music_dir self.config['playlist']['relative_to'] = 'library' self.config['playlist']['playlist_dir'] = self.playlist_dir self.load_plugins('playlist') From 055f2d3702e40a62dff9ed9469af03e487b2d548 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 16:00:04 +0100 Subject: [PATCH 111/149] playlist: Also catch IOErrors to restore Python 2.7 compatiblity --- beetsplug/playlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index a6ab8d18b..759eaa51b 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -41,7 +41,7 @@ class PlaylistQuery(beets.dbcore.FieldQuery): try: f = open(beets.util.syspath(playlist_path), mode='rb') - except OSError: + except (OSError, IOError): continue if config['relative_to'].get() == 'library': From 31c687c853670ab5b58d51f372ae4b0bc2fbe74f Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 16:17:47 +0100 Subject: [PATCH 112/149] test: Fix playlist plugin path handling for Windows compatibility --- test/test_playlist.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index f10076220..62528dac1 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -87,7 +87,10 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): ])) def test_query_path(self): - q = u'playlist:{0}/test.m3u'.format(self.playlist_dir) + q = u'playlist:{0}'.format(os.path.join( + self.playlist_dir, + 'test.m3u', + )) results = self.lib.items(q) self.assertEqual(set([i.title for i in results]), set([ u'some item', @@ -100,7 +103,10 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.assertEqual(set(results), set()) def test_query_path_nonexisting(self): - q = u'playlist:{0}/nonexisting.m3u'.format(self.playlist_dir) + q = u'playlist:{0}'.format(os.path.join( + self.playlist_dir, + 'nonexisting.m3u', + )) results = self.lib.items(q) self.assertEqual(set(results), set()) From d6022e28d72fa1dc5521fbde34d22b307235ad8d Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 16:43:36 +0100 Subject: [PATCH 113/149] test: Ensure path quoting in playlist tests --- test/test_playlist.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 62528dac1..529f3631c 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -14,6 +14,7 @@ # included in all copies or substantial portions of the Software. from __future__ import division, absolute_import, print_function +from six.moves import shlex_quote import os import shutil @@ -87,10 +88,10 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): ])) def test_query_path(self): - q = u'playlist:{0}'.format(os.path.join( + q = u'playlist:{0}'.format(shlex_quote(os.path.join( self.playlist_dir, 'test.m3u', - )) + ))) results = self.lib.items(q) self.assertEqual(set([i.title for i in results]), set([ u'some item', @@ -103,10 +104,11 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.assertEqual(set(results), set()) def test_query_path_nonexisting(self): - q = u'playlist:{0}'.format(os.path.join( + q = u'playlist:{0}'.format(shlex_quote(os.path.join( + self.playlist_dir, self.playlist_dir, 'nonexisting.m3u', - )) + ))) results = self.lib.items(q) self.assertEqual(set(results), set()) From 4f1a468aa944b37aadcef3c7164bf7f4576bb957 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 17:34:36 +0100 Subject: [PATCH 114/149] playlist: Restore case sensitivity in col_clause method --- beetsplug/playlist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 759eaa51b..e5c80f129 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -67,8 +67,7 @@ class PlaylistQuery(beets.dbcore.FieldQuery): if not self.paths: # Playlist is empty return '0', () - clause = 'BYTELOWER(path) IN ({0})'.format( - ', '.join('BYTELOWER(?)' for path in self.paths)) + clause = 'path IN ({0})'.format(', '.join('?' for path in self.paths)) return clause, (beets.library.BLOB_TYPE(p) for p in self.paths) def match(self, item): From 7360bbc1526b1da664527fcc871f9a50cb5db21b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:06:55 -0500 Subject: [PATCH 115/149] Only pin Jellyfish version on py2 --- docs/changelog.rst | 3 ++- setup.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1298090aa..028049ed7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -86,7 +86,8 @@ Changes: Fixes: -* Pin jellyfish requirement to version 0.6.0 to maintain python 2 compatibility. +* On Python 2, pin the Jellyfish requirement to version 0.6.0 for + compatibility. * A new importer option, :ref:`ignore_data_tracks`, lets you skip audio tracks contained in data files :bug:`3021` * Restore iTunes Store album art source, and remove the dependency on diff --git a/setup.py b/setup.py index 19efb9451..648e6d4d4 100755 --- a/setup.py +++ b/setup.py @@ -92,9 +92,16 @@ setup( 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', - 'jellyfish==0.6.0', - ] + (['colorama'] if (sys.platform == 'win32') else []) + - (['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else []), + ] + ( + # Use the backport of Python 3.4's `enum` module. + ['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else [] + ) + ( + # Pin a Python 2-compatible version of Jellyfish. + ['jellyfish==0.6.0'] if sys.version_info < (3, 4, 0) else ['jellyfish'] + ) + ( + # Support for ANSI console colors on Windows. + ['colorama'] if (sys.platform == 'win32') else [] + ), tests_require=[ 'beautifulsoup4', From 3633c1e27f892c8076cb863f236c12d53547b7e0 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:17:22 -0500 Subject: [PATCH 116/149] Tiny doc refinements for #3145 --- docs/changelog.rst | 4 ++-- docs/plugins/index.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6c7b36b72..a254d5efc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,10 +14,10 @@ New features: issues with foobar2000 and Winamp. Thanks to :user:`mz2212`. :bug:`2944` -* :doc:`/plugins/playlist`: Add a plugin that can query the beets library using +* A new :doc:`/plugins/playlist` can query the beets library using M3U playlists. Thanks to :user:`Holzhaus` and :user:`Xenopathic`. - :bug:`123` + :bug:`123` :bug:`3145` * Added whitespace padding to missing tracks dialog to improve readability. Thanks to :user:`jams2`. :bug:`2962` diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 502775095..173aab5db 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -159,7 +159,7 @@ Interoperability * :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library changes. * :doc:`play`: Play beets queries in your music player. -* :doc:`playlist`: Use M3U playlists tp query the beets library. +* :doc:`playlist`: Use M3U playlists to query the beets library. * :doc:`plexupdate`: Automatically notifies `Plex`_ whenever the beets library changes. * :doc:`smartplaylist`: Generate smart playlists based on beets queries. From 14cad04d35c6e99ea72c17b61b2c6f42812363d1 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 19:07:21 +0100 Subject: [PATCH 117/149] test: Further improve Windows compatibility in playlist plugin test --- test/test_playlist.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 529f3631c..3dd80c35f 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -37,7 +37,7 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): i1 = _common.item() i1.path = beets.util.normpath(os.path.join( self.music_dir, - 'a/b/c.mp3', + 'a', 'b', 'c.mp3', )) i1.title = u'some item' i1.album = u'some album' @@ -47,7 +47,7 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): i2 = _common.item() i2.path = beets.util.normpath(os.path.join( self.music_dir, - 'd/e/f.mp3', + 'd', 'e', 'f.mp3', )) i2.title = 'another item' i2.album = 'another album' @@ -57,7 +57,7 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): i3 = _common.item() i3.path = beets.util.normpath(os.path.join( self.music_dir, - 'x/y/z.mp3', + 'x', 'y', 'z.mp3', )) i3.title = 'yet another item' i3.album = 'yet another album' From b00b38dab6fdea37e2fc7fe201388dea84768a7b Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 19:09:46 +0100 Subject: [PATCH 118/149] test: Add test for relative playlists --- test/test_playlist.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 3dd80c35f..b01a36d07 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -65,9 +65,12 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.lib.add_album([i3]) self.playlist_dir = tempfile.mkdtemp() - with open(os.path.join(self.playlist_dir, 'test.m3u'), 'w') as f: + with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) + with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) self.config['directory'] = self.music_dir self.config['playlist']['relative_to'] = 'library' @@ -79,18 +82,18 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): shutil.rmtree(self.playlist_dir) self.teardown_beets() - def test_query_name(self): - q = u'playlist:test' + def test_name_query_with_absolute_paths_in_playlist(self): + q = u'playlist:absolute' results = self.lib.items(q) self.assertEqual(set([i.title for i in results]), set([ u'some item', u'another item', ])) - def test_query_path(self): + def test_path_query_with_absolute_paths_in_playlist(self): q = u'playlist:{0}'.format(shlex_quote(os.path.join( self.playlist_dir, - 'test.m3u', + 'absolute.m3u', ))) results = self.lib.items(q) self.assertEqual(set([i.title for i in results]), set([ @@ -98,12 +101,31 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): u'another item', ])) - def test_query_name_nonexisting(self): + def test_name_query_with_relative_paths_in_playlist(self): + q = u'playlist:relative' + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_path_query_with_relative_paths_in_playlist(self): + q = u'playlist:{0}'.format(shlex_quote(os.path.join( + self.playlist_dir, + 'relative.m3u', + ))) + results = self.lib.items(q) + self.assertEqual(set([i.title for i in results]), set([ + u'some item', + u'another item', + ])) + + def test_name_query_with_nonexisting_playlist(self): q = u'playlist:nonexisting'.format(self.playlist_dir) results = self.lib.items(q) self.assertEqual(set(results), set()) - def test_query_path_nonexisting(self): + def test_path_query_with_nonexisting_playlist(self): q = u'playlist:{0}'.format(shlex_quote(os.path.join( self.playlist_dir, self.playlist_dir, From 9f3acce2aef55595e67e0368e07aa9bec91e5472 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 19:14:37 +0100 Subject: [PATCH 119/149] test: Add non-existing item to playlist tests --- test/test_playlist.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_playlist.py b/test/test_playlist.py index b01a36d07..4408e69d7 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -68,9 +68,12 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'nonexisting.mp3'))) with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\n'.format('nonexisting.mp3')) self.config['directory'] = self.music_dir self.config['playlist']['relative_to'] = 'library' From 5b68d883466509a5736de0dd8aecf671fcdefd76 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 19:27:56 +0100 Subject: [PATCH 120/149] test: Add more playlist tests for the different relative_to settings --- test/test_playlist.py | 82 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 4408e69d7..e05e61550 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -27,7 +27,7 @@ from test import helper import beets -class PlaylistTest(unittest.TestCase, helper.TestHelper): +class PlaylistTestHelper(helper.TestHelper): def setUp(self): self.setup_beets() self.lib = beets.library.Library(':memory:') @@ -65,21 +65,15 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.lib.add_album([i3]) self.playlist_dir = tempfile.mkdtemp() - with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: - f.write('{0}\n'.format(beets.util.displayable_path(i1.path))) - f.write('{0}\n'.format(beets.util.displayable_path(i2.path))) - f.write('{0}\n'.format(os.path.join( - self.music_dir, 'nonexisting.mp3'))) - with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: - f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) - f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) - f.write('{0}\n'.format('nonexisting.mp3')) - self.config['directory'] = self.music_dir - self.config['playlist']['relative_to'] = 'library' self.config['playlist']['playlist_dir'] = self.playlist_dir + + self.setup_test() self.load_plugins('playlist') + def setup_test(self): + raise NotImplementedError + def tearDown(self): self.unload_plugins() shutil.rmtree(self.playlist_dir) @@ -138,6 +132,70 @@ class PlaylistTest(unittest.TestCase, helper.TestHelper): self.assertEqual(set(results), set()) +class PlaylistTestRelativeToLib(PlaylistTestHelper, unittest.TestCase): + def setup_test(self): + with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'nonexisting.mp3'))) + + with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\n'.format('nonexisting.mp3')) + + self.config['playlist']['relative_to'] = 'library' + + +class PlaylistTestRelativeToDir(PlaylistTestHelper, unittest.TestCase): + def setup_test(self): + with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'nonexisting.mp3'))) + + with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\n'.format('nonexisting.mp3')) + + self.config['playlist']['relative_to'] = self.music_dir + + +class PlaylistTestRelativeToPls(PlaylistTestHelper, unittest.TestCase): + def setup_test(self): + with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'nonexisting.mp3'))) + + with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.relpath( + os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), + start=self.playlist_dir, + ))) + f.write('{0}\n'.format(os.path.relpath( + os.path.join(self.music_dir, 'd', 'e', 'f.mp3'), + start=self.playlist_dir, + ))) + f.write('{0}\n'.format(os.path.relpath( + os.path.join(self.music_dir, 'nonexisting.mp3'), + start=self.playlist_dir, + ))) + + self.config['playlist']['relative_to'] = 'playlist' + self.config['playlist']['playlist_dir'] = self.playlist_dir + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From 6d420280571cc6ad4f4cf422013f0f036b800200 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Fri, 15 Feb 2019 19:50:26 +0100 Subject: [PATCH 121/149] playlist: Add playlist auto-update functionality --- beetsplug/playlist.py | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index e5c80f129..393449217 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -14,6 +14,7 @@ import os import fnmatch +import tempfile import beets @@ -86,6 +87,98 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): def __init__(self): super(PlaylistPlugin, self).__init__() self.config.add({ + 'auto': False, 'playlist_dir': '.', 'relative_to': 'library', }) + + self.playlist_dir = self.config['playlist_dir'].as_filename() + self.changes = {} + + if self.config['relative_to'].get() == 'library': + self.relative_to = beets.util.bytestring_path( + beets.config['directory'].as_filename()) + elif self.config['relative_to'].get() != 'playlist': + print(repr(self.config['relative_to'].get())) + self.relative_to = beets.util.bytestring_path( + self.config['relative_to'].as_filename()) + else: + self.relative_to = None + + if self.config['auto'].get(bool): + self.register_listener('item_moved', self.item_moved) + self.register_listener('item_removed', self.item_removed) + self.register_listener('cli_exit', self.cli_exit) + + def item_moved(self, item, source, destination): + self.changes[source] = destination + + def item_removed(self, item): + if not os.path.exists(beets.util.syspath(item.path)): + self.changes[item.path] = None + + def cli_exit(self, lib): + for playlist in self.find_playlists(): + self._log.info('Updating playlist: {0}'.format(playlist)) + base_dir = beets.util.bytestring_path( + self.relative_to if self.relative_to + else os.path.dirname(playlist) + ) + + try: + self.update_playlist(playlist, base_dir) + except beets.util.FilesystemError: + self._log.error('Failed to update playlist: {0}'.format( + beets.util.displayable_path(playlist))) + + def find_playlists(self): + """Find M3U playlists in the playlist directory.""" + try: + dir_contents = os.listdir(beets.util.syspath(self.playlist_dir)) + except OSError: + self._log.warning('Unable to open playlist directory {0}'.format( + beets.util.displayable_path(self.playlist_dir))) + return + + for filename in dir_contents: + if fnmatch.fnmatch(filename, '*.[mM]3[uU]'): + yield os.path.join(self.playlist_dir, filename) + + def update_playlist(self, filename, base_dir): + """Find M3U playlists in the specified directory.""" + changes = 0 + deletions = 0 + + with tempfile.NamedTemporaryFile(mode='w+b') as tempfp: + with open(filename, mode='rb') as fp: + for line in fp: + original_path = line.rstrip(b'\r\n') + + # Ensure that path from playlist is absolute + is_relative = not os.path.isabs(beets.util.syspath(line)) + if is_relative: + lookup = os.path.join(base_dir, original_path) + else: + lookup = original_path + + try: + new_path = self.changes[lookup] + except KeyError: + tempfp.write(line) + else: + if new_path is None: + # Item has been deleted + deletions += 1 + continue + + changes += 1 + if is_relative: + new_path = os.path.relpath(new_path, base_dir) + + tempfp.write(line.replace(original_path, new_path)) + if changes or deletions: + self._log.info( + 'Updated playlist {0} ({1} changes, {2} deletions)'.format( + filename, changes, deletions)) + tempfp.flush() + beets.util.copy(tempfp.name, filename, replace=True) From d8e167637ec3eb025621d5b50aacca8526877872 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:41:05 -0500 Subject: [PATCH 122/149] Prototype support for named (pseudo-field) queries As discussed here: https://github.com/beetbox/beets/pull/3145#pullrequestreview-204523870 This would replace the need for #3149. --- beets/dbcore/db.py | 5 +++++ beets/dbcore/queryparse.py | 38 ++++++++++++++++++++------------------ test/test_dbcore.py | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index e92cba40c..71810ead2 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -143,6 +143,11 @@ class Model(object): are subclasses of `Sort`. """ + _queries = {} + """Named queries that use a field-like `name:value` syntax but which + do not relate to any specific field. + """ + _always_dirty = False """By default, fields only become "dirty" when their value actually changes. Enabling this flag marks fields as dirty even when the new diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index ce88fa3bd..1cb25a8c7 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -119,12 +119,13 @@ def construct_query_part(model_cls, prefixes, query_part): if not query_part: return query.TrueQuery() - # Use `model_cls` to build up a map from field names to `Query` - # classes. + # Use `model_cls` to build up a map from field (or query) names to + # `Query` classes. query_classes = {} for k, t in itertools.chain(model_cls._fields.items(), model_cls._types.items()): query_classes[k] = t.query + query_classes.update(model_cls._queries) # Non-field queries. # Parse the string. key, pattern, query_class, negate = \ @@ -137,26 +138,27 @@ def construct_query_part(model_cls, prefixes, query_part): # The query type matches a specific field, but none was # specified. So we use a version of the query that matches # any field. - q = query.AnyFieldQuery(pattern, model_cls._search_fields, - query_class) - if negate: - return query.NotQuery(q) - else: - return q + out_query = query.AnyFieldQuery(pattern, model_cls._search_fields, + query_class) else: # Non-field query type. - if negate: - return query.NotQuery(query_class(pattern)) - else: - return query_class(pattern) + out_query = query_class(pattern) - # Otherwise, this must be a `FieldQuery`. Use the field name to - # construct the query object. - key = key.lower() - q = query_class(key.lower(), pattern, key in model_cls._fields) + # Field queries get constructed according to the name of the field + # they are querying. + elif issubclass(query_class, query.FieldQuery): + key = key.lower() + out_query = query_class(key.lower(), pattern, key in model_cls._fields) + + # Non-field (named) query. + else: + out_query = query_class(pattern) + + # Apply negation. if negate: - return query.NotQuery(q) - return q + return query.NotQuery(out_query) + else: + return out_query def query_from_strings(query_cls, model_cls, prefixes, query_parts): diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 89aca442b..34994e3b3 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -36,6 +36,17 @@ class TestSort(dbcore.query.FieldSort): pass +class TestQuery(dbcore.query.Query): + def __init__(self, pattern): + self.pattern = pattern + + def clause(self): + return None, () + + def match(self): + return True + + class TestModel1(dbcore.Model): _table = 'test' _flex_table = 'testflex' @@ -49,6 +60,9 @@ class TestModel1(dbcore.Model): _sorts = { 'some_sort': TestSort, } + _queries = { + 'some_query': TestQuery, + } @classmethod def _getters(cls): @@ -519,6 +533,10 @@ class QueryFromStringsTest(unittest.TestCase): q = self.qfs(['']) self.assertIsInstance(q.subqueries[0], dbcore.query.TrueQuery) + def test_parse_named_query(self): + q = self.qfs(['some_query:foo']) + self.assertIsInstance(q.subqueries[0], TestQuery) + class SortFromStringsTest(unittest.TestCase): def sfs(self, strings): From 1af82cc450929713edfd9d8da1cc7e26d8dd0f21 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sat, 16 Feb 2019 13:59:25 +0100 Subject: [PATCH 123/149] test: Split up playlist test helper class --- test/test_playlist.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index e05e61550..1fcdb0071 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -79,6 +79,8 @@ class PlaylistTestHelper(helper.TestHelper): shutil.rmtree(self.playlist_dir) self.teardown_beets() + +class PlaylistQueryTestHelper(PlaylistTestHelper): def test_name_query_with_absolute_paths_in_playlist(self): q = u'playlist:absolute' results = self.lib.items(q) @@ -132,7 +134,7 @@ class PlaylistTestHelper(helper.TestHelper): self.assertEqual(set(results), set()) -class PlaylistTestRelativeToLib(PlaylistTestHelper, unittest.TestCase): +class PlaylistTestRelativeToLib(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{0}\n'.format(os.path.join( @@ -150,7 +152,7 @@ class PlaylistTestRelativeToLib(PlaylistTestHelper, unittest.TestCase): self.config['playlist']['relative_to'] = 'library' -class PlaylistTestRelativeToDir(PlaylistTestHelper, unittest.TestCase): +class PlaylistTestRelativeToDir(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{0}\n'.format(os.path.join( @@ -168,7 +170,7 @@ class PlaylistTestRelativeToDir(PlaylistTestHelper, unittest.TestCase): self.config['playlist']['relative_to'] = self.music_dir -class PlaylistTestRelativeToPls(PlaylistTestHelper, unittest.TestCase): +class PlaylistTestRelativeToPls(PlaylistQueryTestHelper, unittest.TestCase): def setup_test(self): with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: f.write('{0}\n'.format(os.path.join( From 22c8289269df005ae2440f2540d314b6791ad5d1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:49:54 -0500 Subject: [PATCH 124/149] Support plugin-provided named queries --- beets/plugins.py | 10 ++++++++++ beets/ui/__init__.py | 4 ++++ docs/dev/plugins.rst | 19 ++++++++++++++----- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/beets/plugins.py b/beets/plugins.py index 6dec7ef2a..f10dc5849 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -344,6 +344,16 @@ def types(model_cls): return types +def named_queries(model_cls): + # Gather `item_queries` and `album_queries` from the plugins. + attr_name = '{0}_queries'.format(model_cls.__name__.lower()) + queries = {} + for plugin in find_plugins(): + plugin_queries = getattr(plugin, attr_name, {}) + queries.update(plugin_queries) + return queries + + def track_distance(item, info): """Gets the track distance calculated by all loaded plugins. Returns a Distance object. diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 1abce2e67..327db6b04 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1143,8 +1143,12 @@ def _setup(options, lib=None): if lib is None: lib = _open_library(config) plugins.send("library_opened", lib=lib) + + # Add types and queries defined by plugins. library.Item._types.update(plugins.types(library.Item)) library.Album._types.update(plugins.types(library.Album)) + library.Item._queries.update(plugins.named_queries(library.Item)) + library.Album._queries.update(plugins.named_queries(library.Album)) return subcommands, plugins, lib diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index bab0e604d..c9018c394 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -443,15 +443,24 @@ Extend the Query Syntax ^^^^^^^^^^^^^^^^^^^^^^^ You can add new kinds of queries to beets' :doc:`query syntax -` indicated by a prefix. As an example, beets already +`. There are two ways to add custom queries: using a prefix +and using a name. Prefix-based query extension can apply to *any* field, while +named queries are not associated with any field. For example, beets already supports regular expression queries, which are indicated by a colon prefix---plugins can do the same. -To do so, define a subclass of the ``Query`` type from the -``beets.dbcore.query`` module. Then, in the ``queries`` method of your plugin -class, return a dictionary mapping prefix strings to query classes. +For either kind of query extension, define a subclass of the ``Query`` type +from the ``beets.dbcore.query`` module. Then: -One simple kind of query you can extend is the ``FieldQuery``, which +- To define a prefix-based query, define a ``queries`` method in your plugin + class. Return from this method a dictionary mapping prefix strings to query + classes. +- To define a named query, defined dictionaries named either ``item_queries`` + or ``album_queries``. These should map names to query types. So if you + use ``{ "foo": FooQuery }``, then the query ``foo:bar`` will construct a + query like ``FooQuery("bar")``. + +For prefix-based queries, you will want to extend ``FieldQuery``, which implements string comparisons on fields. To use it, create a subclass inheriting from that class and override the ``value_match`` class method. (Remember the ``@classmethod`` decorator!) The following example plugin From 7efc67eb0361ff731f5c756e43c99107c49058f9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:52:00 -0500 Subject: [PATCH 125/149] playlist: Use new "named query" functionality --- beetsplug/playlist.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index e5c80f129..8b58b03be 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -17,11 +17,11 @@ import fnmatch import beets -class PlaylistQuery(beets.dbcore.FieldQuery): +class PlaylistQuery(beets.dbcore.Query): """Matches files listed by a playlist file. """ - def __init__(self, field, pattern, fast=True): - super(PlaylistQuery, self).__init__(field, pattern, fast) + def __init__(self, pattern): + self.pattern = pattern config = beets.config['playlist'] # Get the full path to the playlist @@ -74,14 +74,8 @@ class PlaylistQuery(beets.dbcore.FieldQuery): return item.path in self.paths -class PlaylistType(beets.dbcore.types.String): - """Custom type for playlist query. - """ - query = PlaylistQuery - - class PlaylistPlugin(beets.plugins.BeetsPlugin): - item_types = {'playlist': PlaylistType()} + item_queries = {'playlist': PlaylistQuery} def __init__(self): super(PlaylistPlugin, self).__init__() From 420772ea497872b99ac61dc242d9f7c75ef06ea9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 13:54:18 -0500 Subject: [PATCH 126/149] Changelog entry for pseudo-field queries --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index a254d5efc..fc6b4cd93 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -144,6 +144,14 @@ Fixes: .. _python-itunes: https://github.com/ocelma/python-itunes +For developers: + +* In addition to prefix-based field queries, plugins can now define *named + queries* that are not associated with any specific field. + For example, the new :doc:`/plugins/playlist` supports queries like + ``playlist:name`` although there is no field named ``playlist``. + See :ref:`extend-query` for details. + 1.4.7 (May 29, 2018) -------------------- From 55ef2ffd39e73f15674a3e5b0438428d5ef50c29 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 14:02:26 -0500 Subject: [PATCH 127/149] Add future imports to playlist plugin --- beetsplug/playlist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index e5c80f129..e8683ff93 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -12,6 +12,8 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. +from __future__ import division, absolute_import, print_function + import os import fnmatch import beets From 7edba6e9eaeca98faf6b73c17d0bd9fd2bff1649 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 17 Feb 2019 14:11:40 -0500 Subject: [PATCH 128/149] Fix test harness for named queries --- test/helper.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/helper.py b/test/helper.py index 92128f511..392d01a55 100644 --- a/test/helper.py +++ b/test/helper.py @@ -222,12 +222,19 @@ class TestHelper(object): beets.config['plugins'] = plugins beets.plugins.load_plugins(plugins) beets.plugins.find_plugins() - # Take a backup of the original _types to restore when unloading + + # Take a backup of the original _types and _queries to restore + # when unloading. Item._original_types = dict(Item._types) Album._original_types = dict(Album._types) Item._types.update(beets.plugins.types(Item)) Album._types.update(beets.plugins.types(Album)) + Item._original_queries = dict(Item._queries) + Album._original_queries = dict(Album._queries) + Item._queries.update(beets.plugins.named_queries(Item)) + Album._queries.update(beets.plugins.named_queries(Album)) + def unload_plugins(self): """Unload all plugins and remove the from the configuration. """ @@ -237,6 +244,8 @@ class TestHelper(object): beets.plugins._instances = {} Item._types = Item._original_types Album._types = Album._original_types + Item._queries = Item._original_queries + Album._queries = Album._original_queries def create_importer(self, item_count=1, album_count=1): """Create files to import and return corresponding session. From a9dd5a7cdc72102fb85b85731542fd203161d589 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sat, 16 Feb 2019 15:37:26 +0100 Subject: [PATCH 129/149] test: Add testcase for playlist plugin's update functionality --- test/test_playlist.py | 103 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/test/test_playlist.py b/test/test_playlist.py index 1fcdb0071..2bc461f76 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -198,6 +198,109 @@ class PlaylistTestRelativeToPls(PlaylistQueryTestHelper, unittest.TestCase): self.config['playlist']['playlist_dir'] = self.playlist_dir +class PlaylistUpdateTestHelper(PlaylistTestHelper): + def setup_test(self): + with open(os.path.join(self.playlist_dir, 'absolute.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'd', 'e', 'f.mp3'))) + f.write('{0}\n'.format(os.path.join( + self.music_dir, 'nonexisting.mp3'))) + + with open(os.path.join(self.playlist_dir, 'relative.m3u'), 'w') as f: + f.write('{0}\n'.format(os.path.join('a', 'b', 'c.mp3'))) + f.write('{0}\n'.format(os.path.join('d', 'e', 'f.mp3'))) + f.write('{0}\n'.format('nonexisting.mp3')) + + self.config['playlist']['auto'] = True + self.config['playlist']['relative_to'] = 'library' + + +class PlaylistTestItemMoved(PlaylistUpdateTestHelper, unittest.TestCase): + def test_item_moved(self): + # Emit item_moved event for an item that is in a playlist + results = self.lib.items('path:{0}'.format(shlex_quote( + os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) + item = results[0] + beets.plugins.send( + 'item_moved', item=item, source=item.path, + destination=beets.util.bytestring_path( + os.path.join(self.music_dir, 'g', 'h', 'i.mp3'))) + + # Emit item_moved event for an item that is not in a playlist + results = self.lib.items('path:{0}'.format(shlex_quote( + os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) + item = results[0] + beets.plugins.send( + 'item_moved', item=item, source=item.path, + destination=beets.util.bytestring_path( + os.path.join(self.music_dir, 'u', 'v', 'w.mp3'))) + + # Emit cli_exit event + beets.plugins.send('cli_exit', lib=self.lib) + + # Check playlist with absolute paths + playlist_path = os.path.join(self.playlist_dir, 'absolute.m3u') + with open(playlist_path, 'r') as f: + lines = [line.strip() for line in f.readlines()] + + self.assertEqual(lines, [ + os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), + os.path.join(self.music_dir, 'g', 'h', 'i.mp3'), + os.path.join(self.music_dir, 'nonexisting.mp3'), + ]) + + # Check playlist with relative paths + playlist_path = os.path.join(self.playlist_dir, 'relative.m3u') + with open(playlist_path, 'r') as f: + lines = [line.strip() for line in f.readlines()] + + self.assertEqual(lines, [ + os.path.join('a', 'b', 'c.mp3'), + os.path.join('g', 'h', 'i.mp3'), + 'nonexisting.mp3', + ]) + + +class PlaylistTestItemRemoved(PlaylistUpdateTestHelper, unittest.TestCase): + def test_item_removed(self): + # Emit item_removed event for an item that is in a playlist + results = self.lib.items('path:{0}'.format(shlex_quote( + os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) + item = results[0] + beets.plugins.send('item_removed', item=item) + + # Emit item_removed event for an item that is not in a playlist + results = self.lib.items('path:{0}'.format(shlex_quote( + os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) + item = results[0] + beets.plugins.send('item_removed', item=item) + + # Emit cli_exit event + beets.plugins.send('cli_exit', lib=self.lib) + + # Check playlist with absolute paths + playlist_path = os.path.join(self.playlist_dir, 'absolute.m3u') + with open(playlist_path, 'r') as f: + lines = [line.strip() for line in f.readlines()] + + self.assertEqual(lines, [ + os.path.join(self.music_dir, 'a', 'b', 'c.mp3'), + os.path.join(self.music_dir, 'nonexisting.mp3'), + ]) + + # Check playlist with relative paths + playlist_path = os.path.join(self.playlist_dir, 'relative.m3u') + with open(playlist_path, 'r') as f: + lines = [line.strip() for line in f.readlines()] + + self.assertEqual(lines, [ + os.path.join('a', 'b', 'c.mp3'), + 'nonexisting.mp3', + ]) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From fdd41b301d272604ded7b980532ee4275e46a71e Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sat, 16 Feb 2019 15:45:04 +0100 Subject: [PATCH 130/149] docs: Update documentation regarding playlist plugin --- docs/plugins/playlist.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plugins/playlist.rst b/docs/plugins/playlist.rst index 1156e7f77..d9b400987 100644 --- a/docs/plugins/playlist.rst +++ b/docs/plugins/playlist.rst @@ -8,6 +8,7 @@ To use it, enable the ``playlist`` plugin in your configuration Then configure your playlists like this:: playlist: + auto: no relative_to: ~/Music playlist_dir: ~/.mpd/playlists @@ -22,6 +23,10 @@ name:: $ beet ls playlist:anotherplaylist +The plugin can also update playlists in the playlist directory automatically +every time an item is moved or deleted. This can be controlled by the ``auto`` +configuration option. + Configuration ------------- @@ -29,6 +34,10 @@ To configure the plugin, make a ``smartplaylist:`` section in your configuration file. In addition to the ``playlists`` described above, the other configuration options are: +- **auto**: If this is set to ``yes``, then anytime an item in the library is + moved or removed, the plugin will update all playlists in the + ``playlist_dir`` directory that contain that item to reflect the change. + Default: ``no`` - **playlist_dir**: Where to read playlist files from. Default: The current working directory (i.e., ``'.'``). - **relative_to**: Interpret paths in the playlist files relative to a base From 7ec55a5f3be4a768d5889dbe8eaf712c27f96700 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 21:27:09 +0100 Subject: [PATCH 131/149] test: Use unicode literals for library queries in playlist tests --- test/test_playlist.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 2bc461f76..1e800804e 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -220,7 +220,7 @@ class PlaylistUpdateTestHelper(PlaylistTestHelper): class PlaylistTestItemMoved(PlaylistUpdateTestHelper, unittest.TestCase): def test_item_moved(self): # Emit item_moved event for an item that is in a playlist - results = self.lib.items('path:{0}'.format(shlex_quote( + results = self.lib.items(u'path:{0}'.format(shlex_quote( os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) item = results[0] beets.plugins.send( @@ -229,7 +229,7 @@ class PlaylistTestItemMoved(PlaylistUpdateTestHelper, unittest.TestCase): os.path.join(self.music_dir, 'g', 'h', 'i.mp3'))) # Emit item_moved event for an item that is not in a playlist - results = self.lib.items('path:{0}'.format(shlex_quote( + results = self.lib.items(u'path:{0}'.format(shlex_quote( os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) item = results[0] beets.plugins.send( @@ -266,13 +266,13 @@ class PlaylistTestItemMoved(PlaylistUpdateTestHelper, unittest.TestCase): class PlaylistTestItemRemoved(PlaylistUpdateTestHelper, unittest.TestCase): def test_item_removed(self): # Emit item_removed event for an item that is in a playlist - results = self.lib.items('path:{0}'.format(shlex_quote( + results = self.lib.items(u'path:{0}'.format(shlex_quote( os.path.join(self.music_dir, 'd', 'e', 'f.mp3')))) item = results[0] beets.plugins.send('item_removed', item=item) # Emit item_removed event for an item that is not in a playlist - results = self.lib.items('path:{0}'.format(shlex_quote( + results = self.lib.items(u'path:{0}'.format(shlex_quote( os.path.join(self.music_dir, 'x', 'y', 'z.mp3')))) item = results[0] beets.plugins.send('item_removed', item=item) From 76a3e44aaddbd1aa23ffe8e3b6d8d2878d769738 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 21:27:37 +0100 Subject: [PATCH 132/149] test: Make music dir of playlist tests Windows-compatible --- test/test_playlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_playlist.py b/test/test_playlist.py index 1e800804e..edd98e711 100644 --- a/test/test_playlist.py +++ b/test/test_playlist.py @@ -32,7 +32,7 @@ class PlaylistTestHelper(helper.TestHelper): self.setup_beets() self.lib = beets.library.Library(':memory:') - self.music_dir = os.path.expanduser('~/Music') + self.music_dir = os.path.expanduser(os.path.join('~', 'Music')) i1 = _common.item() i1.path = beets.util.normpath(os.path.join( From d991e2a7d8cf3a55dbad4797f084fe24f4d8016d Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 21:51:09 +0100 Subject: [PATCH 133/149] playlist: Normalize path before lookup in changes dict --- beetsplug/playlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 393449217..68b2adb49 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -162,7 +162,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): lookup = original_path try: - new_path = self.changes[lookup] + new_path = self.changes[beets.util.normpath(lookup)] except KeyError: tempfp.write(line) else: From ee2cce4280b14c8ade99fd838bea330ebd1360f8 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 22:05:54 +0100 Subject: [PATCH 134/149] playlist: Work around Windows' Mandatory File Locking on playlist updates --- beetsplug/playlist.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 68b2adb49..ae530aaec 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -149,7 +149,8 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): changes = 0 deletions = 0 - with tempfile.NamedTemporaryFile(mode='w+b') as tempfp: + with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as tempfp: + new_playlist = tempfp.name with open(filename, mode='rb') as fp: for line in fp: original_path = line.rstrip(b'\r\n') @@ -176,9 +177,10 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): new_path = os.path.relpath(new_path, base_dir) tempfp.write(line.replace(original_path, new_path)) - if changes or deletions: - self._log.info( - 'Updated playlist {0} ({1} changes, {2} deletions)'.format( - filename, changes, deletions)) - tempfp.flush() - beets.util.copy(tempfp.name, filename, replace=True) + + if changes or deletions: + self._log.info( + 'Updated playlist {0} ({1} changes, {2} deletions)'.format( + filename, changes, deletions)) + beets.util.copy(new_playlist, filename, replace=True) + beets.util.remove(new_playlist) From 7bca5cf549c9f4569c8f2616e67b4f52afb0bd82 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Sun, 17 Feb 2019 22:28:39 +0100 Subject: [PATCH 135/149] playlist: Don't use syspath() when checking if path is absolute --- beetsplug/playlist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index ae530aaec..08c4bc8af 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -156,7 +156,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): original_path = line.rstrip(b'\r\n') # Ensure that path from playlist is absolute - is_relative = not os.path.isabs(beets.util.syspath(line)) + is_relative = not os.path.isabs(line) if is_relative: lookup = os.path.join(base_dir, original_path) else: From 4ba5dfaa43bffd3470b10e857388516e6590b19e Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Mon, 18 Feb 2019 09:13:39 +0100 Subject: [PATCH 136/149] playlist: Remove leftover print call and fix 'auto' option access style --- beetsplug/playlist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/playlist.py b/beetsplug/playlist.py index 08c4bc8af..04959c431 100644 --- a/beetsplug/playlist.py +++ b/beetsplug/playlist.py @@ -99,13 +99,12 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin): self.relative_to = beets.util.bytestring_path( beets.config['directory'].as_filename()) elif self.config['relative_to'].get() != 'playlist': - print(repr(self.config['relative_to'].get())) self.relative_to = beets.util.bytestring_path( self.config['relative_to'].as_filename()) else: self.relative_to = None - if self.config['auto'].get(bool): + if self.config['auto']: self.register_listener('item_moved', self.item_moved) self.register_listener('item_removed', self.item_removed) self.register_listener('cli_exit', self.cli_exit) From c4506558f552e96dcd893483c3ad92829a363961 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Date: Mon, 18 Feb 2019 22:19:41 -0800 Subject: [PATCH 137/149] Added par_map utility --- beets/util/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 69870edf2..ba7393975 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -24,6 +24,7 @@ import re import shutil import fnmatch from collections import Counter +from multiprocessing.pool import ThreadPool import traceback import subprocess import platform @@ -1009,3 +1010,22 @@ def asciify_path(path, sep_replace): sep_replace ) return os.sep.join(path_components) + + +def par_map(transform, items): + """ + This module implements a simple utility to either: + a) Perform a parallel map when running under Python >=3 + b) Perform a sequential map otherwise + + This is useful whenever there is some operation `do_something()` which we + want to efficiently apply to our music library. + """ + if sys.version_info[0] < 3: + for item in items: + transform(item) + else: + pool = ThreadPool() + pool.map(transform, items) + pool.close() + pool.join() From 1dad5ded039451023fa281481f605d95793c044e Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Date: Mon, 18 Feb 2019 22:26:59 -0800 Subject: [PATCH 138/149] Move absubmit plugin parallelization to util.par_map --- beetsplug/absubmit.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index 5cce11bc0..9d26ac5db 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -24,9 +24,7 @@ import json import os import subprocess import tempfile -import sys -from multiprocessing.pool import ThreadPool from distutils.spawn import find_executable import requests @@ -106,15 +104,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin): def command(self, lib, opts, args): # Get items from arguments items = lib.items(ui.decargs(args)) - if sys.version_info[0] < 3: - for item in items: - self.analyze_submit(item) - else: - # Analyze in parallel using a thread pool. - pool = ThreadPool() - pool.map(self.analyze_submit, items) - pool.close() - pool.join() + util.par_map(self.analyze_submit, items) def analyze_submit(self, item): analysis = self._get_analysis(item) From c07903ed666e6182dc7842bf79bae6cc05bfe79e Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Tue, 19 Feb 2019 16:16:56 +0100 Subject: [PATCH 139/149] fetchart: Add some error handling to prevent crashes Today I had some network problems regarding dbpedia.org, which made beets crash because a requests.exceptions.ConnectionError was raised ("[Errno 113] No route to host"). This commits adds some error handling around network requests to prevent further crashes in the future. --- beetsplug/fetchart.py | 109 +++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index d7a885315..bfda94670 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -365,12 +365,17 @@ class GoogleImages(RemoteArtSource): if not (album.albumartist and album.album): return search_string = (album.albumartist + ',' + album.album).encode('utf-8') - response = self.request(self.URL, params={ - 'key': self.key, - 'cx': self.cx, - 'q': search_string, - 'searchType': 'image' - }) + + try: + response = self.request(self.URL, params={ + 'key': self.key, + 'cx': self.cx, + 'q': search_string, + 'searchType': 'image' + }) + except requests.RequestException: + self._log.debug(u'google: error receiving response') + return # Get results using JSON. try: @@ -406,10 +411,14 @@ class FanartTV(RemoteArtSource): if not album.mb_releasegroupid: return - response = self.request( - self.API_ALBUMS + album.mb_releasegroupid, - headers={'api-key': self.PROJECT_KEY, - 'client-key': self.client_key}) + try: + response = self.request( + self.API_ALBUMS + album.mb_releasegroupid, + headers={'api-key': self.PROJECT_KEY, + 'client-key': self.client_key}) + except requests.RequestException: + self._log.debug(u'fanart.tv: error receiving response') + return try: data = response.json() @@ -545,16 +554,22 @@ class Wikipedia(RemoteArtSource): # Find the name of the cover art filename on DBpedia cover_filename, page_id = None, None - dbpedia_response = self.request( - self.DBPEDIA_URL, - params={ - 'format': 'application/sparql-results+json', - 'timeout': 2500, - 'query': self.SPARQL_QUERY.format( - artist=album.albumartist.title(), album=album.album) - }, - headers={'content-type': 'application/json'}, - ) + + try: + dbpedia_response = self.request( + self.DBPEDIA_URL, + params={ + 'format': 'application/sparql-results+json', + 'timeout': 2500, + 'query': self.SPARQL_QUERY.format( + artist=album.albumartist.title(), album=album.album) + }, + headers={'content-type': 'application/json'}, + ) + except requests.RequestException: + self._log.debug(u'dbpedia: error receiving response') + return + try: data = dbpedia_response.json() results = data['results']['bindings'] @@ -584,17 +599,21 @@ class Wikipedia(RemoteArtSource): lpart, rpart = cover_filename.rsplit(' .', 1) # Query all the images in the page - wikipedia_response = self.request( - self.WIKIPEDIA_URL, - params={ - 'format': 'json', - 'action': 'query', - 'continue': '', - 'prop': 'images', - 'pageids': page_id, - }, - headers={'content-type': 'application/json'}, - ) + try: + wikipedia_response = self.request( + self.WIKIPEDIA_URL, + params={ + 'format': 'json', + 'action': 'query', + 'continue': '', + 'prop': 'images', + 'pageids': page_id, + }, + headers={'content-type': 'application/json'}, + ) + except requests.RequestException: + self._log.debug(u'wikipedia: error receiving response') + return # Try to see if one of the images on the pages matches our # incomplete cover_filename @@ -613,18 +632,22 @@ class Wikipedia(RemoteArtSource): return # Find the absolute url of the cover art on Wikipedia - wikipedia_response = self.request( - self.WIKIPEDIA_URL, - params={ - 'format': 'json', - 'action': 'query', - 'continue': '', - 'prop': 'imageinfo', - 'iiprop': 'url', - 'titles': cover_filename.encode('utf-8'), - }, - headers={'content-type': 'application/json'}, - ) + try: + wikipedia_response = self.request( + self.WIKIPEDIA_URL, + params={ + 'format': 'json', + 'action': 'query', + 'continue': '', + 'prop': 'imageinfo', + 'iiprop': 'url', + 'titles': cover_filename.encode('utf-8'), + }, + headers={'content-type': 'application/json'}, + ) + except requests.RequestException: + self._log.debug(u'wikipedia: error receiving response') + return try: data = wikipedia_response.json() From 10bc4665732cdda2502de16a11dd28cd7aba6404 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 19 Feb 2019 18:36:19 -0500 Subject: [PATCH 140/149] Refine docs for #3153 --- beets/util/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index ba7393975..f3dedcb41 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1013,15 +1013,17 @@ def asciify_path(path, sep_replace): def par_map(transform, items): - """ - This module implements a simple utility to either: - a) Perform a parallel map when running under Python >=3 - b) Perform a sequential map otherwise + """Apply the function `transform` to all the elements in the + iterable `items`, like `map(transform, items)` but with no return + value. The map *might* happen in parallel: it's parallel on Python 3 + and sequential on Python 2. - This is useful whenever there is some operation `do_something()` which we - want to efficiently apply to our music library. + The parallelism uses threads (not processes), so this is only useful + for IO-bound `transform`s. """ if sys.version_info[0] < 3: + # multiprocessing.pool.ThreadPool does not seem to work on + # Python 2. We could consider switching to futures instead. for item in items: transform(item) else: From e209fe5886129ba17fa52d7dfeeb14a059a121d4 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Date: Sat, 18 Aug 2018 12:12:20 -0300 Subject: [PATCH 141/149] Parallelized `beet bad` --- beetsplug/badfiles.py | 101 ++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 62c6d8af5..23a9c446e 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -20,7 +20,7 @@ from __future__ import division, absolute_import, print_function from beets.plugins import BeetsPlugin from beets.ui import Subcommand -from beets.util import displayable_path, confit +from beets.util import displayable_path, confit, par_map from beets import ui from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT import shlex @@ -48,6 +48,9 @@ class CheckerCommandException(Exception): class BadFiles(BeetsPlugin): + def __init__(self): + self.verbose = False + def run_command(self, cmd): self._log.debug(u"running command: {}", displayable_path(list2cmdline(cmd))) @@ -89,56 +92,60 @@ class BadFiles(BeetsPlugin): command = None if command: return self.check_custom(command) - elif ext == "mp3": + if ext == "mp3": return self.check_mp3val - elif ext == "flac": + if ext == "flac": return self.check_flac - def check_bad(self, lib, opts, args): - for item in lib.items(ui.decargs(args)): + def check_item(self, item): + # First, check whether the path exists. If not, the user + # should probably run `beet update` to cleanup your library. + dpath = displayable_path(item.path) + self._log.debug(u"checking path: {}", dpath) + if not os.path.exists(item.path): + ui.print_(u"{}: file does not exist".format( + ui.colorize('text_error', dpath))) - # First, check whether the path exists. If not, the user - # should probably run `beet update` to cleanup your library. - dpath = displayable_path(item.path) - self._log.debug(u"checking path: {}", dpath) - if not os.path.exists(item.path): - ui.print_(u"{}: file does not exist".format( - ui.colorize('text_error', dpath))) + # Run the checker against the file if one is found + ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore') + checker = self.get_checker(ext) + if not checker: + self._log.error(u"no checker specified in the config for {}", + ext) + return + path = item.path + if not isinstance(path, six.text_type): + path = item.path.decode(sys.getfilesystemencoding()) + try: + status, errors, output = checker(path) + except CheckerCommandException as e: + if e.errno == errno.ENOENT: + self._log.error( + u"command not found: {} when validating file: {}", + e.checker, + e.path + ) + else: + self._log.error(u"error invoking {}: {}", e.checker, e.msg) + return + if status > 0: + ui.print_(u"{}: checker exited with status {}" + .format(ui.colorize('text_error', dpath), status)) + for line in output: + ui.print_(u" {}".format(displayable_path(line))) + elif errors > 0: + ui.print_(u"{}: checker found {} errors or warnings" + .format(ui.colorize('text_warning', dpath), errors)) + for line in output: + ui.print_(u" {}".format(displayable_path(line))) + elif self.verbose: + ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath))) - # Run the checker against the file if one is found - ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore') - checker = self.get_checker(ext) - if not checker: - self._log.error(u"no checker specified in the config for {}", - ext) - continue - path = item.path - if not isinstance(path, six.text_type): - path = item.path.decode(sys.getfilesystemencoding()) - try: - status, errors, output = checker(path) - except CheckerCommandException as e: - if e.errno == errno.ENOENT: - self._log.error( - u"command not found: {} when validating file: {}", - e.checker, - e.path - ) - else: - self._log.error(u"error invoking {}: {}", e.checker, e.msg) - continue - if status > 0: - ui.print_(u"{}: checker exited with status {}" - .format(ui.colorize('text_error', dpath), status)) - for line in output: - ui.print_(u" {}".format(displayable_path(line))) - elif errors > 0: - ui.print_(u"{}: checker found {} errors or warnings" - .format(ui.colorize('text_warning', dpath), errors)) - for line in output: - ui.print_(u" {}".format(displayable_path(line))) - elif opts.verbose: - ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath))) + def command(self, lib, opts, args): + # Get items from arguments + items = lib.items(ui.decargs(args)) + self.verbose = opts.verbose + par_map(self.check_item, items) def commands(self): bad_command = Subcommand('bad', @@ -148,5 +155,5 @@ class BadFiles(BeetsPlugin): action='store_true', default=False, dest='verbose', help=u'view results for both the bad and uncorrupted files' ) - bad_command.func = self.check_bad + bad_command.func = self.command return [bad_command] From 9374983e9dbb9a13d338417c1ee530a777c81757 Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Date: Sat, 18 Aug 2018 12:25:05 -0300 Subject: [PATCH 142/149] Fixed import order --- beetsplug/badfiles.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 23a9c446e..52caa9994 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -18,16 +18,17 @@ from __future__ import division, absolute_import, print_function -from beets.plugins import BeetsPlugin -from beets.ui import Subcommand -from beets.util import displayable_path, confit, par_map -from beets import ui from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT + import shlex import os import errno import sys import six +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand +from beets.util import displayable_path, confit, par_map +from beets import ui class CheckerCommandException(Exception): From c3c7aa619d6fd4230c79d8f755b802e42f2087db Mon Sep 17 00:00:00 2001 From: Bernardo Meurer Date: Sat, 18 Aug 2018 12:13:28 -0300 Subject: [PATCH 143/149] Updated changelog --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index fc6b4cd93..bb95c46ae 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,6 +75,8 @@ New features: Thanks to :user:`wildthyme`. * :doc:`/plugins/discogs`: The plugin has rate limiting for the discogs API now. :bug:`3081` +* The `badfiles` plugin now works in parallel (on Python 3 only). + Thanks to :user:`bemeurer`. Changes: From 9b5e681f86b8c273f874b21fb83f13e1dacdec8b Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Wed, 20 Feb 2019 07:50:22 +0100 Subject: [PATCH 144/149] docs: Add fetchart error handling fix to changelog The changelog entry also mentions that this fixes #1579. --- docs/changelog.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index fc6b4cd93..bdb8b1603 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -140,6 +140,10 @@ Fixes: * The ``%title`` template function now works correctly with apostrophes. Thanks to :user:`GuilhermeHideki`. :bug:`3033` +* :doc:`/plugins/fetchart`: Added network connection error handling to backends + so that beets won't crash if a request fails. + Thanks to :user:`Holzhaus`. + :bug:`1579` * Fetchart now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` .. _python-itunes: https://github.com/ocelma/python-itunes From 3e10d5d39f9f0ab12fa51b5ed6de69ab1bb1ad00 Mon Sep 17 00:00:00 2001 From: Jan Holthuis Date: Thu, 21 Feb 2019 12:40:54 +0100 Subject: [PATCH 145/149] badfiles: Fix #3158 by calling superclass __init__ method --- beetsplug/badfiles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 52caa9994..0be08bae5 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -50,6 +50,7 @@ class CheckerCommandException(Exception): class BadFiles(BeetsPlugin): def __init__(self): + super(BadFiles, self).__init__() self.verbose = False def run_command(self, cmd): From 80f4f0a0f235b9764f516990c174f6b73695175b Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 24 Feb 2019 16:06:36 -0500 Subject: [PATCH 146/149] badfiles: Fix decoding for command output Probably fixes #3165. There were several things going wrong here: 1. For some reason, this was using the *filesystem* encoding, which is what you use to decode filenames. But this was general command output, not filenames. 2. Errors in decoding threw exceptions, even though all we do with this output is show it to the user. 3. The prints were using `displayable_path`, even though the lines are *already* Unicode strings. Hopefully this cleans up that mess. --- beetsplug/badfiles.py | 6 +++--- docs/changelog.rst | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 0be08bae5..fdfbf204a 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -66,7 +66,7 @@ class BadFiles(BeetsPlugin): status = e.returncode except OSError as e: raise CheckerCommandException(cmd, e) - output = output.decode(sys.getfilesystemencoding()) + output = output.decode(sys.getdefaultencoding(), 'replace') return status, errors, [line for line in output.split("\n") if line] def check_mp3val(self, path): @@ -134,12 +134,12 @@ class BadFiles(BeetsPlugin): ui.print_(u"{}: checker exited with status {}" .format(ui.colorize('text_error', dpath), status)) for line in output: - ui.print_(u" {}".format(displayable_path(line))) + ui.print_(u" {}".format(line)) elif errors > 0: ui.print_(u"{}: checker found {} errors or warnings" .format(ui.colorize('text_warning', dpath), errors)) for line in output: - ui.print_(u" {}".format(displayable_path(line))) + ui.print_(u" {}".format(line)) elif self.verbose: ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath))) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8e02c974e..f311571d5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -147,6 +147,9 @@ Fixes: Thanks to :user:`Holzhaus`. :bug:`1579` * Fetchart now respects the ``ignore`` and ``ignore_hidden`` settings. :bug:`1632` +* :doc:`/plugins/badfiles`: Avoid a crash when the underlying tool emits + undecodable output. + :bug:`3165` .. _python-itunes: https://github.com/ocelma/python-itunes From 9bb6c29d22cfa3b01c9152600a214951528b5703 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 25 Feb 2019 09:52:36 +0000 Subject: [PATCH 147/149] Always use custom formatter for formatting hook commands --- beetsplug/hook.py | 10 ++-------- docs/changelog.rst | 2 ++ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/beetsplug/hook.py b/beetsplug/hook.py index de44c1b81..ac0c4acad 100644 --- a/beetsplug/hook.py +++ b/beetsplug/hook.py @@ -18,7 +18,6 @@ from __future__ import division, absolute_import, print_function import string import subprocess -import six from beets.plugins import BeetsPlugin from beets.util import shlex_split, arg_encoding @@ -46,10 +45,8 @@ class CodingFormatter(string.Formatter): See str.format and string.Formatter.format. """ - try: + if isinstance(format_string, bytes): format_string = format_string.decode(self._coding) - except UnicodeEncodeError: - pass return super(CodingFormatter, self).format(format_string, *args, **kwargs) @@ -96,10 +93,7 @@ class HookPlugin(BeetsPlugin): return # Use a string formatter that works on Unicode strings. - if six.PY2: - formatter = CodingFormatter(arg_encoding()) - else: - formatter = string.Formatter() + formatter = CodingFormatter(arg_encoding()) command_pieces = shlex_split(command) diff --git a/docs/changelog.rst b/docs/changelog.rst index f311571d5..7e98a8360 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -150,6 +150,8 @@ Fixes: * :doc:`/plugins/badfiles`: Avoid a crash when the underlying tool emits undecodable output. :bug:`3165` +* :doc:`/plugins/hook`: Fix byte string interpolation in hook commands. + :bug:`2967` :bug:`3167` .. _python-itunes: https://github.com/ocelma/python-itunes From 25549a656f47b1cd383b850c2220e3b911f067f7 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Mon, 25 Feb 2019 09:39:33 +0000 Subject: [PATCH 148/149] Add test for interpolating byte strings in hook plugin --- test/test_hook.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/test_hook.py b/test/test_hook.py index 39fd08959..81363c73c 100644 --- a/test/test_hook.py +++ b/test/test_hook.py @@ -110,6 +110,25 @@ class HookTest(_common.TestCase, TestHelper): self.assertTrue(os.path.isfile(path)) os.remove(path) + def test_hook_bytes_interpolation(self): + temporary_paths = [ + get_temporary_path().encode('utf-8') + for i in range(self.TEST_HOOK_COUNT) + ] + + for index, path in enumerate(temporary_paths): + self._add_hook('test_bytes_event_{0}'.format(index), + 'touch "{path}"') + + self.load_plugins('hook') + + for index, path in enumerate(temporary_paths): + plugins.send('test_bytes_event_{0}'.format(index), path=path) + + for path in temporary_paths: + self.assertTrue(os.path.isfile(path)) + os.remove(path) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) From f312b1f0b778bcc4b140f41022b2549047d48684 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 25 Feb 2019 10:06:14 -0500 Subject: [PATCH 149/149] Fix #3168: several versions of munkres Require different version constraints for Pythons <3.5, =3.5, and >3.5. --- setup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 648e6d4d4..ae8f76ff8 100755 --- a/setup.py +++ b/setup.py @@ -88,10 +88,14 @@ setup( install_requires=[ 'six>=1.9', 'mutagen>=1.33', - 'munkres~=1.0.0', 'unidecode', 'musicbrainzngs>=0.4', 'pyyaml', + ] + [ + # Avoid a version of munkres incompatible with Python 3. + 'munkres~=1.0.0' if sys.version_info < (3, 5, 0) else + 'munkres!=1.1.0,!=1.1.1' if sys.version_info < (3, 6, 0) else + 'munkres>=1.0.0', ] + ( # Use the backport of Python 3.4's `enum` module. ['enum34>=1.0.4'] if sys.version_info < (3, 4, 0) else []