diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 38db0a07b8..ee1bf051c5 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -186,7 +186,7 @@ def apply_metadata(album_info, mapping): 'mb_workid', 'work_disambig', 'bpm', - 'musical_key', + 'initial_key', 'genre' ) } diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index f59aaea420..c0f0ace7be 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -180,7 +180,7 @@ def __init__(self, title, track_id, release_track_id=None, artist=None, data_url=None, media=None, lyricist=None, composer=None, composer_sort=None, arranger=None, track_alt=None, work=None, mb_workid=None, work_disambig=None, bpm=None, - musical_key=None, genre=None): + initial_key=None, genre=None): self.title = title self.track_id = track_id self.release_track_id = release_track_id @@ -206,7 +206,7 @@ def __init__(self, title, track_id, release_track_id=None, artist=None, self.mb_workid = mb_workid self.work_disambig = work_disambig self.bpm = bpm - self.musical_key = musical_key + self.initial_key = initial_key self.genre = genre # As above, work around a bug in python-musicbrainz-ngs. diff --git a/beets/plugins.py b/beets/plugins.py index b0752203f1..73d85cdd3e 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -206,7 +206,7 @@ def add_media_field(self, name, descriptor): ``descriptor`` must be an instance of ``mediafile.MediaField``. """ - # Defer impor to prevent circular dependency + # Defer import to prevent circular dependency from beets import library mediafile.MediaFile.add_field(name, descriptor) library.Item._media_fields.add(name) @@ -590,6 +590,36 @@ def get_distance(config, data_source, info): return dist +def apply_item_changes(lib, item, move, pretend, write): + """Store, move, and write the item according to the arguments. + + :param lib: beets library. + :type lib: beets.library.Library + :param item: Item whose changes to apply. + :type item: beets.library.Item + :param move: Move the item if it's in the library. + :type move: bool + :param pretend: Return without moving, writing, or storing the item's + metadata. + :type pretend: bool + :param write: Write the item's metadata to its media file. + :type write: bool + """ + if pretend: + return + + from beets import util + + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(item.path): + item.move(with_album=False) + + if write: + item.try_write() + + item.store() + + @six.add_metaclass(abc.ABCMeta) class MetadataSourcePlugin(object): def __init__(self): @@ -633,13 +663,20 @@ def get_artist(artists, id_key='id', name_key='name'): """Returns an artist string (all artists) and an artist_id (the main artist) for a list of artist object dicts. - :param artists: Iterable of artist dicts returned by API. - :type artists: list[dict] - :param id_key: Key corresponding to ``artist_id`` value. - :type id_key: str - :param name_key: Keys corresponding to values to concatenate - for ``artist``. - :type name_key: str + For each artist, this function moves articles (such as 'a', 'an', + and 'the') to the front and strips trailing disambiguation numbers. It + returns a tuple containing the comma-separated string of all + normalized artists and the ``id`` of the main/first artist. + + :param artists: Iterable of artist dicts or lists returned by API. + :type artists: list[dict] or list[list] + :param id_key: Key or index corresponding to the value of ``id`` for + the main/first artist. Defaults to 'id'. + :type id_key: str or int + :param name_key: Key or index corresponding to values of names + to concatenate for the artist string (containing all artists). + Defaults to 'name'. + :type name_key: str or int :return: Normalized artist string. :rtype: str """ @@ -649,6 +686,8 @@ def get_artist(artists, id_key='id', name_key='name'): if not artist_id: artist_id = artist[id_key] name = artist[name_key] + # 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) @@ -694,8 +733,9 @@ def candidates(self, items, artist, album, va_likely): query_filters = {'album': album} if not va_likely: query_filters['artist'] = artist - albums = self._search_api(query_type='album', filters=query_filters) - return [self.album_for_id(album_id=a['id']) for a in albums] + results = self._search_api(query_type='album', filters=query_filters) + albums = [self.album_for_id(album_id=r['id']) for r in results] + return [a for a in albums if a is not None] def item_candidates(self, item, artist, title): """Returns a list of TrackInfo objects for Search API results diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index be76b902f6..6a45ab93ab 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -28,8 +28,8 @@ import beets import beets.ui -from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance -from beets.plugins import BeetsPlugin +from beets.autotag.hooks import AlbumInfo, TrackInfo +from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance import confuse @@ -228,6 +228,7 @@ def __init__(self, data): if 'slug' in data: self.url = "https://beatport.com/release/{0}/{1}".format( data['slug'], data['id']) + self.genre = data.get('genre') @six.python_2_unicode_compatible @@ -258,7 +259,7 @@ def __init__(self, data): .format(data['slug'], data['id']) self.track_number = data.get('trackNumber') self.bpm = data.get('bpm') - self.musical_key = six.text_type( + self.initial_key = six.text_type( (data.get('key') or {}).get('shortName') ) @@ -270,6 +271,8 @@ def __init__(self, data): class BeatportPlugin(BeetsPlugin): + data_source = 'Beatport' + def __init__(self): super(BeatportPlugin, self).__init__() self.config.add({ @@ -333,22 +336,24 @@ def _tokenfile(self): return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True)) def album_distance(self, items, album_info, mapping): - """Returns the beatport source weight and the maximum source weight + """Returns the Beatport source weight and the maximum source weight for albums. """ - dist = Distance() - if album_info.data_source == 'Beatport': - dist.add('source', self.config['source_weight'].as_number()) - return dist + return get_distance( + data_source=self.data_source, + info=album_info, + config=self.config + ) def track_distance(self, item, track_info): - """Returns the beatport source weight and the maximum source weight + """Returns the Beatport source weight and the maximum source weight for individual tracks. """ - dist = Distance() - if track_info.data_source == 'Beatport': - dist.add('source', self.config['source_weight'].as_number()) - return dist + return get_distance( + data_source=self.data_source, + info=track_info, + config=self.config + ) def candidates(self, items, artist, release, va_likely): """Returns a list of AlbumInfo objects for beatport search results @@ -435,7 +440,8 @@ def _get_album_info(self, release): day=release.release_date.day, label=release.label_name, catalognum=release.catalog_number, media=u'Digital', - data_source=u'Beatport', data_url=release.url) + data_source=self.data_source, data_url=release.url, + genre=release.genre) def _get_track_info(self, track): """Returns a TrackInfo object for a Beatport Track object. @@ -449,26 +455,17 @@ def _get_track_info(self, track): artist=artist, artist_id=artist_id, length=length, index=track.track_number, medium_index=track.track_number, - data_source=u'Beatport', data_url=track.url, - bpm=track.bpm, musical_key=track.musical_key) + data_source=self.data_source, data_url=track.url, + bpm=track.bpm, initial_key=track.initial_key, + genre=track.genre) 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 - bits = [] - for artist in artists: - if not artist_id: - artist_id = artist[0] - name = artist[1] - # 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) - bits.append(name) - artist = ', '.join(bits).replace(' ,', ',') or None - return artist, artist_id + return MetadataSourcePlugin.get_artist( + artists=artists, id_key=0, name_key=1 + ) def _get_tracks(self, query): """Returns a list of TrackInfo objects for a Beatport query. diff --git a/beetsplug/bpsync.py b/beetsplug/bpsync.py new file mode 100644 index 0000000000..aefb1517b6 --- /dev/null +++ b/beetsplug/bpsync.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2019, Rahul Ahuja. +# +# 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. + +"""Update library's tags using Beatport. +""" +from __future__ import division, absolute_import, print_function + +from beets.plugins import BeetsPlugin, apply_item_changes +from beets import autotag, library, ui, util + +from .beatport import BeatportPlugin + + +class BPSyncPlugin(BeetsPlugin): + def __init__(self): + super(BPSyncPlugin, self).__init__() + self.beatport_plugin = BeatportPlugin() + self.beatport_plugin.setup() + + def commands(self): + cmd = ui.Subcommand('bpsync', help=u'update metadata from Beatport') + cmd.parser.add_option( + u'-p', + u'--pretend', + action='store_true', + help=u'show all changes but do nothing', + ) + cmd.parser.add_option( + u'-m', + u'--move', + action='store_true', + dest='move', + help=u"move files in the library directory", + ) + cmd.parser.add_option( + u'-M', + u'--nomove', + action='store_false', + dest='move', + help=u"don't move files in library", + ) + cmd.parser.add_option( + u'-W', + u'--nowrite', + action='store_false', + default=None, + dest='write', + help=u"don't write updated metadata to files", + ) + cmd.parser.add_format_option() + cmd.func = self.func + return [cmd] + + def func(self, lib, opts, args): + """Command handler for the bpsync function. + """ + move = ui.should_move(opts.move) + pretend = opts.pretend + write = ui.should_write(opts.write) + query = ui.decargs(args) + + self.singletons(lib, query, move, pretend, write) + self.albums(lib, query, move, pretend, write) + + def singletons(self, lib, query, move, pretend, write): + """Retrieve and apply info from the autotagger for items matched by + query. + """ + for item in lib.items(query + [u'singleton:true']): + if not item.mb_trackid: + self._log.info( + u'Skipping singleton with no mb_trackid: {}', item + ) + continue + + if not self.is_beatport_track(item): + self._log.info( + u'Skipping non-{} singleton: {}', + self.beatport_plugin.data_source, + item, + ) + continue + + # Apply. + trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid) + with lib.transaction(): + autotag.apply_item_metadata(item, trackinfo) + apply_item_changes(lib, item, move, pretend, write) + + @staticmethod + def is_beatport_track(item): + return ( + item.get('data_source') == BeatportPlugin.data_source + and item.mb_trackid.isnumeric() + ) + + def get_album_tracks(self, album): + if not album.mb_albumid: + self._log.info(u'Skipping album with no mb_albumid: {}', album) + return False + if not album.mb_albumid.isnumeric(): + self._log.info( + u'Skipping album with invalid {} ID: {}', + self.beatport_plugin.data_source, + album, + ) + return False + items = list(album.items()) + if album.get('data_source') == self.beatport_plugin.data_source: + return items + if not all(self.is_beatport_track(item) for item in items): + self._log.info( + u'Skipping non-{} release: {}', + self.beatport_plugin.data_source, + album, + ) + return False + return items + + def albums(self, lib, query, move, pretend, write): + """Retrieve and apply info from the autotagger for albums matched by + query and their items. + """ + # Process matching albums. + for album in lib.albums(query): + # Do we have a valid Beatport album? + items = self.get_album_tracks(album) + if not items: + continue + + # Get the Beatport album information. + albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid) + if not albuminfo: + self._log.info( + u'Release ID {} not found for album {}', + album.mb_albumid, + album, + ) + continue + + beatport_trackid_to_trackinfo = { + track.track_id: track for track in albuminfo.tracks + } + library_trackid_to_item = { + int(item.mb_trackid): item for item in items + } + item_to_trackinfo = { + item: beatport_trackid_to_trackinfo[track_id] + for track_id, item in library_trackid_to_item.items() + } + + self._log.info(u'applying changes to {}', album) + with lib.transaction(): + autotag.apply_metadata(albuminfo, item_to_trackinfo) + changed = False + # Find any changed item to apply Beatport changes to album. + any_changed_item = items[0] + for item in items: + item_changed = ui.show_model_changes(item) + changed |= item_changed + if item_changed: + any_changed_item = item + apply_item_changes(lib, item, move, pretend, write) + + if pretend or not changed: + continue + + # Update album structure to reflect an item in it. + for key in library.Album.item_keys: + album[key] = any_changed_item[key] + album.store() + + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + self._log.debug(u'moving album {}', album) + album.move() diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index a4337d2737..4e3fca33a6 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -83,6 +83,8 @@ def album_for_id(self, album_id): tracks_data = requests.get( self.album_url + deezer_id + '/tracks' ).json()['data'] + if not tracks_data: + return None tracks = [] medium_totals = collections.defaultdict(int) for i, track_data in enumerate(tracks_data, start=1): diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index b8121d9c90..a2b3bc4aa4 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -17,7 +17,7 @@ """ from __future__ import division, absolute_import, print_function -from beets.plugins import BeetsPlugin +from beets.plugins import BeetsPlugin, apply_item_changes from beets import autotag, library, ui, util from beets.autotag import hooks from collections import defaultdict @@ -27,19 +27,6 @@ MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}" -def apply_item_changes(lib, item, move, pretend, write): - """Store, move and write the item according to the arguments. - """ - if not pretend: - # Move the item if it's in the library. - if move and lib.directory in util.ancestry(item.path): - item.move(with_album=False) - - if write: - item.try_write() - item.store() - - class MBSyncPlugin(BeetsPlugin): def __init__(self): super(MBSyncPlugin, self).__init__() diff --git a/docs/changelog.rst b/docs/changelog.rst index af9f29c1fe..963863ae88 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -75,6 +75,11 @@ New features: :bug:`2080` * :doc:`/plugins/beatport`: Fix default assignment of the musical key. :bug:`3377` +* :doc:`/plugins/bpsync`: Add `bpsync` plugin to sync metadata changes + from the Beatport database. +* :doc:`/plugins/beatport`: Fix assignment of `genre` and rename `musical_key` + to `initial_key`. + :bug:`3387` Fixes: diff --git a/docs/plugins/bpsync.rst b/docs/plugins/bpsync.rst new file mode 100644 index 0000000000..29cbd08e3a --- /dev/null +++ b/docs/plugins/bpsync.rst @@ -0,0 +1,34 @@ +BPSync Plugin +============= + +This plugin provides the ``bpsync`` command, which lets you fetch metadata +from Beatport for albums and tracks that already have Beatport IDs. +This plugin works similarly to :doc:`/plugins/mbsync`. + +If you have downloaded music from Beatport, this can speed +up the initial import if you just import "as-is" and then use ``bpsync`` to +get up-to-date tags that are written to the files according to your beets +configuration. + + +Usage +----- + +Enable the ``bpsync`` plugin in your configuration (see :ref:`using-plugins`) +and then run ``beet bpsync QUERY`` to fetch updated metadata for a part of your +collection (or omit the query to run over your whole library). + +This plugin treats albums and singletons (non-album tracks) separately. It +first processes all matching singletons and then proceeds on to full albums. +The same query is used to search for both kinds of entities. + +The command has a few command-line options: + +* To preview the changes that would be made without applying them, use the + ``-p`` (``--pretend``) flag. +* By default, files will be moved (renamed) according to their metadata if + they are inside your beets library directory. To disable this, use the + ``-M`` (``--nomove``) command-line option. +* If you have the ``import.write`` configuration option enabled, then this + plugin will write new metadata to files' tags. To disable this, use the + ``-W`` (``--nowrite``) option. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 24e427dce3..56864b2e09 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -65,6 +65,7 @@ following to your configuration:: beatport bpd bpm + bpsync bucket chroma convert @@ -142,6 +143,7 @@ Metadata * :doc:`absubmit`: Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server * :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata * :doc:`bpm`: Measure tempo using keystrokes. +* :doc:`bpsync`: Fetch updated metadata from Beatport. * :doc:`edit`: Edit metadata from a text editor. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`fetchart`: Fetch album cover art from various sources. @@ -154,7 +156,7 @@ Metadata * :doc:`lastgenre`: Fetch genres based on Last.fm tags. * :doc:`lastimport`: Collect play counts from Last.fm. * :doc:`lyrics`: Automatically fetch song lyrics. -* :doc:`mbsync`: Fetch updated metadata from MusicBrainz +* :doc:`mbsync`: Fetch updated metadata from MusicBrainz. * :doc:`metasync`: Fetch metadata from local or remote sources * :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play statistics (last_played, play_count, skip_count, rating). diff --git a/test/test_beatport.py b/test/test_beatport.py index 8e830df76c..fb39627f87 100644 --- a/test/test_beatport.py +++ b/test/test_beatport.py @@ -482,12 +482,12 @@ def mk_test_album(self): items[4].bpm = 123 items[5].bpm = 123 - items[0].musical_key = 'Gmin' - items[1].musical_key = 'Gmaj' - items[2].musical_key = 'Fmaj' - items[3].musical_key = 'Amin' - items[4].musical_key = 'E♭maj' - items[5].musical_key = 'Amaj' + items[0].initial_key = 'Gmin' + items[1].initial_key = 'Gmaj' + items[2].initial_key = 'Fmaj' + items[3].initial_key = 'Amin' + items[4].initial_key = 'E♭maj' + items[5].initial_key = 'Amaj' for item in items: self.lib.add(item) @@ -549,9 +549,9 @@ def test_bpm_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): self.assertEqual(track.bpm, test_track.bpm) - def test_musical_key_applied(self): + def test_initial_key_applied(self): for track, test_track in zip(self.tracks, self.test_tracks): - self.assertEqual(track.musical_key, test_track.musical_key) + self.assertEqual(track.initial_key, test_track.initial_key) def test_genre_applied(self): for track, test_track in zip(self.tracks, self.test_tracks):