diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 38db0a07b..ee1bf051c 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 f59aaea42..c0f0ace7b 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -180,7 +180,7 @@ class TrackInfo(object): 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 @@ class TrackInfo(object): 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/library.py b/beets/library.py index 59791959d..239d96e81 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1648,6 +1648,18 @@ class DefaultTemplateFunctions(object): return falseval +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() + # Get the name of tmpl_* functions in the above class. DefaultTemplateFunctions._func_names = \ [s for s in dir(DefaultTemplateFunctions) diff --git a/beets/plugins.py b/beets/plugins.py index b0752203f..9a0f2cc73 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -26,7 +26,7 @@ from functools import wraps import beets -from beets import logging +from beets import library, logging, ui, util import mediafile import six @@ -635,11 +635,11 @@ class MetadataSourcePlugin(object): :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 + :param id_key: Key or index corresponding to ``artist_id`` value. + :type id_key: str or int + :param name_key: Key or index corresponding to values to concatenate for ``artist``. - :type name_key: str + :type name_key: str or int :return: Normalized artist string. :rtype: str """ @@ -649,6 +649,8 @@ class MetadataSourcePlugin(object): 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) @@ -724,3 +726,4 @@ class MetadataSourcePlugin(object): return get_distance( data_source=self.data_source, info=track_info, config=self.config ) + diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index be76b902f..360a40173 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -29,7 +29,7 @@ from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing, import beets import beets.ui from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance -from beets.plugins import BeetsPlugin +from beets.plugins import BeetsPlugin, MetadataSourcePlugin import confuse @@ -228,6 +228,7 @@ class BeatportRelease(BeatportObject): 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 @@ class BeatportTrack(BeatportObject): .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 @@ class BeatportTrack(BeatportObject): class BeatportPlugin(BeetsPlugin): + data_source = 'Beatport' + def __init__(self): super(BeatportPlugin, self).__init__() self.config.add({ @@ -337,7 +340,7 @@ class BeatportPlugin(BeetsPlugin): for albums. """ dist = Distance() - if album_info.data_source == 'Beatport': + if album_info.data_source == self.data_source: dist.add('source', self.config['source_weight'].as_number()) return dist @@ -346,7 +349,7 @@ class BeatportPlugin(BeetsPlugin): for individual tracks. """ dist = Distance() - if track_info.data_source == 'Beatport': + if track_info.data_source == self.data_source: dist.add('source', self.config['source_weight'].as_number()) return dist @@ -435,7 +438,8 @@ class BeatportPlugin(BeetsPlugin): 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 +453,15 @@ class BeatportPlugin(BeetsPlugin): 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 000000000..0cd4c903a --- /dev/null +++ b/beetsplug/bpsync.py @@ -0,0 +1,192 @@ +# -*- 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 MusicBrainz. +""" +from __future__ import division, absolute_import, print_function + +from beets.plugins import BeetsPlugin +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. + track_info = self.beatport_plugin.track_for_id(item.mb_trackid) + with lib.transaction(): + autotag.apply_item_metadata(item, track_info) + library.apply_item_changes(lib, item, move, pretend, write) + + @staticmethod + def is_beatport_track(track): + return ( + track.get('data_source') == BeatportPlugin.data_source + and track.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 + tracks = list(album.items()) + if album.get('data_source') == self.beatport_plugin.data_source: + return tracks + if not all(self.is_beatport_track(track) for track in tracks): + self._log.info( + u'Skipping non-{} release: {}', + self.beatport_plugin.data_source, + album, + ) + return False + return tracks + + 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. + album_info = self.beatport_plugin.album_for_id(album.mb_albumid) + if not album_info: + self._log.info( + u'Release ID {} not found for album {}', + album.mb_albumid, + album, + ) + continue + + beatport_track_id_to_info = { + track.track_id: track for track in album_info.tracks + } + library_track_id_to_item = { + int(item.mb_trackid): item for item in items + } + item_to_info_mapping = { + library_track_id_to_item[track_id]: track_info + for track_id, track_info in beatport_track_id_to_info.items() + } + + self._log.info(u'applying changes to {}', album) + with lib.transaction(): + autotag.apply_metadata(album_info, item_to_info_mapping) + 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 + library.apply_item_changes( + lib, item, move, pretend, write + ) + + if not changed: + # No change to any item. + continue + + if not pretend: + # 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/mbsync.py b/beetsplug/mbsync.py index b8121d9c9..c3ef1674c 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -27,19 +27,6 @@ import re 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__() @@ -103,7 +90,7 @@ class MBSyncPlugin(BeetsPlugin): # Apply. with lib.transaction(): autotag.apply_item_metadata(item, track_info) - apply_item_changes(lib, item, move, pretend, write) + library.apply_item_changes(lib, item, move, pretend, write) def albums(self, lib, query, move, pretend, write): """Retrieve and apply info from the autotagger for albums matched by @@ -175,7 +162,7 @@ class MBSyncPlugin(BeetsPlugin): changed |= item_changed if item_changed: any_changed_item = item - apply_item_changes(lib, item, move, pretend, write) + library.apply_item_changes(lib, item, move, pretend, write) if not changed: # No change to any item. diff --git a/test/test_beatport.py b/test/test_beatport.py index 8e830df76..fb39627f8 100644 --- a/test/test_beatport.py +++ b/test/test_beatport.py @@ -482,12 +482,12 @@ class BeatportTest(_common.TestCase, TestHelper): 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 @@ class BeatportTest(_common.TestCase, TestHelper): 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):