diff --git a/beets/library.py b/beets/library.py index f07ea7a53..390d4e1dd 100644 --- a/beets/library.py +++ b/beets/library.py @@ -798,6 +798,33 @@ class ResultIterator(object): row = self.rowiter.next() # May raise StopIteration. return Item(row) +def get_query(val, album=False): + """Takes a value which may be None, a query string, a query string + list, or a Query object, and returns a suitable Query object. album + determines whether the query is to match items or albums. + """ + if album: + default_fields = ALBUM_DEFAULT_FIELDS + all_keys = ALBUM_KEYS + else: + default_fields = ITEM_DEFAULT_FIELDS + all_keys = ITEM_KEYS + + # Convert a single string into a list of space-separated + # criteria. + if isinstance(val, basestring): + val = val.split() + + if val is None: + return TrueQuery() + elif isinstance(val, list) or isinstance(val, tuple): + return AndQuery.from_strings(val, default_fields, all_keys) + elif isinstance(val, Query): + return val + else: + raise ValueError('query must be None or have type Query or str') + + # An abstract library. @@ -809,37 +836,6 @@ class BaseLibrary(object): raise NotImplementedError - # Helpers. - - @classmethod - def _get_query(cls, val=None, album=False): - """Takes a value which may be None, a query string, a query - string list, or a Query object, and returns a suitable Query - object. album determines whether the query is to match items - or albums. - """ - if album: - default_fields = ALBUM_DEFAULT_FIELDS - all_keys = ALBUM_KEYS - else: - default_fields = ITEM_DEFAULT_FIELDS - all_keys = ITEM_KEYS - - # Convert a single string into a list of space-separated - # criteria. - if isinstance(val, basestring): - val = val.split() - - if val is None: - return TrueQuery() - elif isinstance(val, list) or isinstance(val, tuple): - return AndQuery.from_strings(val, default_fields, all_keys) - elif isinstance(val, Query): - return val - elif not isinstance(val, Query): - raise ValueError('query must be None or have type Query or str') - - # Basic operations. def add(self, item, copy=False): @@ -1358,7 +1354,7 @@ class Library(BaseLibrary): # Querying. def albums(self, query=None, artist=None): - query = self._get_query(query, True) + query = get_query(query, True) if artist is not None: # "Add" the artist to the query. query = AndQuery((query, MatchQuery('albumartist', artist))) @@ -1372,7 +1368,7 @@ class Library(BaseLibrary): return [Album(self, dict(res)) for res in rows] def items(self, query=None, artist=None, album=None, title=None): - queries = [self._get_query(query, False)] + queries = [get_query(query, False)] if artist is not None: queries.append(MatchQuery('artist', artist)) if album is not None: diff --git a/beetsplug/convert.py b/beetsplug/convert.py index aee2f55f0..3ce3e34a4 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -39,7 +39,8 @@ def encode(source, dest): encode.wait() if encode.returncode != 0: # Something went wrong (probably Ctrl+C), remove temporary files - log.info(u'Encoding {0} failed. Cleaning up...'.format(source)) + log.info(u'Encoding {0} failed. Cleaning up...' + .format(util.displayable_path(source))) util.remove(dest) util.prune_dirs(os.path.dirname(dest)) return diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py new file mode 100644 index 000000000..97ecee3ae --- /dev/null +++ b/beetsplug/mbsync.py @@ -0,0 +1,159 @@ +# This file is part of beets. +# Copyright 2013, Jakob Schnitzer. +# +# 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. +""" +import logging + +from beets.plugins import BeetsPlugin +from beets import autotag, library, ui, util +from beets.autotag import hooks +from beets import config + +log = logging.getLogger('beets') + + +def _print_and_apply_changes(lib, item, move, pretend, write): + """Apply changes to an Item and preview them in the console. Return + a boolean indicating whether any changes were made. + """ + changes = {} + for key in library.ITEM_KEYS_META: + if item.dirty[key]: + changes[key] = item.old_data[key], getattr(item, key) + if not changes: + return False + + # Something changed. + ui.print_obj(item, lib) + for key, (oldval, newval) in changes.iteritems(): + ui.commands._showdiff(key, oldval, newval) + + # If we're just pretending, then don't move or save. + if not pretend: + # Move the item if it's in the library. + if move and lib.directory in util.ancestry(item.path): + lib.move(item, with_album=False) + + if write: + item.write() + lib.store(item) + + return True + + +def mbsync_singletons(lib, query, move, pretend, write): + """Synchronize matching singleton items. + """ + singletons_query = library.get_query(query, False) + singletons_query.subqueries.append(library.SingletonQuery(True)) + for s in lib.items(singletons_query): + if not s.mb_trackid: + log.info(u'Skipping singleton {0}: has no mb_trackid' + .format(s.title)) + continue + + s.old_data = dict(s.record) + + # Get the MusicBrainz recording info. + track_info = hooks._track_for_id(s.mb_trackid) + if not track_info: + log.info(u'Recording ID not found: {0}'.format(s.mb_trackid)) + continue + + # Apply. + with lib.transaction(): + autotag.apply_item_metadata(s, track_info) + _print_and_apply_changes(lib, s, move, pretend, write) + + +def mbsync_albums(lib, query, move, pretend, write): + """Synchronize matching albums. + """ + # Process matching albums. + for a in lib.albums(query): + if not a.mb_albumid: + log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id)) + continue + + items = list(a.items()) + for item in items: + item.old_data = dict(item.record) + + # Get the MusicBrainz album information. + album_info = hooks._album_for_id(a.mb_albumid) + if not album_info: + log.info(u'Release ID not found: {0}'.format(a.mb_albumid)) + continue + + # Construct a track mapping according to MBIDs. This should work + # for albums that have missing or extra tracks. + mapping = {} + for item in items: + for track_info in album_info.tracks: + if item.mb_trackid == track_info.track_id: + mapping[item] = track_info + break + + # Apply. + with lib.transaction(): + autotag.apply_metadata(album_info, mapping) + changed = False + for item in items: + changed = changed or \ + _print_and_apply_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_KEYS_ITEM: + setattr(a, key, getattr(items[0], key)) + + # Move album art (and any inconsistent items). + if move and lib.directory in util.ancestry(items[0].path): + log.debug(u'moving album {0}'.format(a.id)) + a.move() + + +def mbsync_func(lib, opts, args): + """Command handler for the mbsync function. + """ + move = opts.move + pretend = opts.pretend + write = opts.write + query = ui.decargs(args) + + mbsync_singletons(lib, query, move, pretend, write) + mbsync_albums(lib, query, move, pretend, write) + + +class MBSyncPlugin(BeetsPlugin): + def __init__(self): + super(MBSyncPlugin, self).__init__() + + def commands(self): + cmd = ui.Subcommand('mbsync', + help='update metadata from musicbrainz') + cmd.parser.add_option('-p', '--pretend', action='store_true', + help='show all changes but do nothing') + cmd.parser.add_option('-M', '--nomove', action='store_false', + default=True, dest='move', + help="don't move files in library") + cmd.parser.add_option('-W', '--nowrite', action='store_false', + default=config['import']['write'], dest='write', + help="don't write updated metadata to files") + cmd.func = mbsync_func + return [cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index f7ad1b07b..49ce4edfa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,10 @@ New configuration options: Other stuff: +* A new :doc:`/plugins/mbsync` provides a command that looks up each item and + track in MusicBrainz and updates your library to reflect it. This can help + you easily correct errors that have been fixed in the MB database. Thanks to + Jakob Schnitzer. * :doc:`/plugins/echonest_tempo`: API errors now issue a warning instead of exiting with an exception. We also avoid an error when track metadata contains newlines. diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2217f4d91..3c9eb4d9f 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -60,6 +60,7 @@ disabled by default, but you can turn them on as described above. convert info smartplaylist + mbsync Autotagger Extensions '''''''''''''''''''''' @@ -73,6 +74,7 @@ Metadata * :doc:`lyrics`: Automatically fetch song lyrics. * :doc:`echonest_tempo`: Automatically fetch song tempos (bpm). * :doc:`lastgenre`: Fetch genres based on Last.fm tags. +* :doc:`mbsync`: Fetch updated metadata from MusicBrainz * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`replaygain`: Calculate volume normalization for players that support it. diff --git a/docs/plugins/mbsync.rst b/docs/plugins/mbsync.rst new file mode 100644 index 000000000..4bb3da32f --- /dev/null +++ b/docs/plugins/mbsync.rst @@ -0,0 +1,35 @@ +MBSync Plugin +============= + +This plugin provides the ``mbsync`` command, which lets you fetch metadata +from MusicBrainz for albums and tracks that already have MusicBrainz IDs. This +is useful for updating tags as they are fixed in the MusicBrainz database, or +when you change your mind about some config options that change how tags are +written to files. If you have a music library that is already nicely tagged by +a program that also uses MusicBrainz like Picard, this can speed up the +initial import if you just import "as-is" and then use ``mbsync`` to get +up-to-date tags that are written to the files according to your beets +configuration. + + +Usage +----- + +Enable the plugin and then run ``beet mbsync 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/reference/cli.rst b/docs/reference/cli.rst index 2add6aaba..575c92968 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -105,10 +105,9 @@ right now; this is something we need to work on. Read the ^^^^^^^^^^^ The ``import`` command can also be used to "reimport" music that you've - already added to your library. This is useful for updating tags as they are - fixed in the MusicBrainz database, for when you change your mind about some - selections you made during the initial import, or if you prefer to import - everything "as-is" and then correct tags later. + already added to your library. This is useful when you change your mind + about some selections you made during the initial import, or if you prefer + to import everything "as-is" and then correct tags later. Just point the ``beet import`` command at a directory of files that are already catalogged in your library. Beets will automatically detect this @@ -127,6 +126,11 @@ right now; this is something we need to work on. Read the or full albums. If you want to retag your whole library, just supply a null query, which matches everything: ``beet import -L`` + Note that, if you just want to update your files' tags according to + changes in the MusicBrainz database, the :doc:`/plugins/mbsync` is a + better choice. Reimporting uses the full matching machinery to guess + metadata matches; ``mbsync`` just relies on MusicBrainz IDs. + .. _list-cmd: list